Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd342563d | ||
|
|
8a31fda88f | ||
|
|
c4e139af19 | ||
|
|
389f75fa7b | ||
|
|
34eb19a5ab | ||
|
|
0c57b1d5d2 | ||
|
|
5d847ffc88 | ||
|
|
2a52b2901f | ||
|
|
7f4f7bd8d2 | ||
|
|
2729483851 | ||
|
|
5226d5a728 | ||
|
|
dec085e1e5 | ||
|
|
eaf96c60ae | ||
|
|
85bbc35176 | ||
|
|
3ce51a3487 | ||
|
|
a031ff6c7b | ||
|
|
0d748500d6 | ||
|
|
d47857a20f | ||
|
|
c9236ed3f7 | ||
|
|
2480a505cb | ||
|
|
231a97dd51 | ||
|
|
1819921e03 | ||
|
|
06226bcb73 | ||
|
|
20eaecd624 | ||
|
|
653945c8f8 | ||
|
|
bfde4dd918 | ||
|
|
a71348821a | ||
|
|
4f60296e77 | ||
|
|
98ccc7980b | ||
|
|
1639666a34 | ||
|
|
47af13c3d4 | ||
|
|
1da3c0c052 | ||
|
|
b19731b307 | ||
|
|
2d95ac1694 | ||
|
|
68241453dd | ||
|
|
39099dcc57 | ||
|
|
9ea392e6ee | ||
|
|
fffec4a714 | ||
|
|
adfe6e3820 | ||
|
|
935a588aa2 | ||
|
|
212fb9994a | ||
|
|
f5111045a9 | ||
|
|
c3e0ca3dfa | ||
|
|
50dbe4c10e | ||
|
|
ec41d72e0d | ||
|
|
63026402a1 | ||
|
|
a785b53e7b | ||
|
|
1217d58ca1 | ||
|
|
c912ef59d1 | ||
|
|
af505b197a | ||
|
|
ed14640756 |
No files matched your search
Generated
+115
@@ -200,6 +200,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
@@ -713,6 +719,16 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.8"
|
||||
@@ -790,6 +806,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -861,6 +886,19 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"colored",
|
||||
"getopts",
|
||||
"image",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1066,6 +1104,27 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
|
||||
dependencies = [
|
||||
"dify",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
"image",
|
||||
"kittest",
|
||||
"log",
|
||||
"open",
|
||||
"pollster 1.0.1",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"toml",
|
||||
"wgpu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
@@ -1438,6 +1497,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -1940,6 +2008,25 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
@@ -2067,6 +2154,16 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "kittest"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer 0.35.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
@@ -2119,6 +2216,12 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -2740,6 +2843,16 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.55"
|
||||
@@ -4744,8 +4857,10 @@ dependencies = [
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
@@ -11,6 +11,7 @@ rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/vectorcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
|
||||
+5
-1
@@ -1,5 +1,9 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
|
||||
|
||||
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
@@ -123,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). |
|
||||
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and DisLine truncated
|
||||
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
|
||||
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
|
||||
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |
|
||||
|
||||
@@ -189,7 +189,42 @@ fn read_prefs() -> Option<vectorcraft_ui_egui::UiState> {
|
||||
return None;
|
||||
}
|
||||
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
decode_saved_prefs(&bytes)
|
||||
}
|
||||
|
||||
/// Recover only optional persisted docking metadata. Runtime UiState and command decoding
|
||||
/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences.
|
||||
fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
fn recover(object: &mut Map<String, Value>, hidden_key: &str) {
|
||||
if let Some(layout) = object.get("docking")
|
||||
&& serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking": layout})).is_err()
|
||||
{
|
||||
object.remove("docking");
|
||||
}
|
||||
if let Some(hidden) = object.get_mut(hidden_key) {
|
||||
if let Value::Object(entries) = hidden {
|
||||
entries.retain(|panel, location| {
|
||||
serde_json::from_value::<vectorcraft_ui_egui::UiState>(json!({"docking_hidden": {panel: location}})).is_ok()
|
||||
});
|
||||
} else {
|
||||
object.remove(hidden_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut value: Value = serde_json::from_slice(bytes).ok()?;
|
||||
let object = value.as_object_mut()?;
|
||||
recover(object, "docking_hidden");
|
||||
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
|
||||
for workspace in workspaces {
|
||||
if let Some(object) = workspace.as_object_mut() {
|
||||
recover(object, "dockingHidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::from_value(value).ok()
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_egui::UiState>) {
|
||||
@@ -649,6 +684,51 @@ mod tests {
|
||||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
|
||||
use serde_json::json;
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("window.panel.float", json!({"panel":"layers"})).unwrap();
|
||||
app.ui.toolbar = false;
|
||||
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
|
||||
app.ui.custom_workspaces =
|
||||
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
|
||||
let original = serde_json::to_value(&app.ui).unwrap();
|
||||
let valid_location = original["docking_hidden"]["layers"].clone();
|
||||
assert!(valid_location.is_object());
|
||||
for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] {
|
||||
for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] {
|
||||
let mut raw = original.clone();
|
||||
raw["docking"] = invalid_layout.clone();
|
||||
raw["docking_hidden"] = invalid_hidden.clone();
|
||||
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
|
||||
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
|
||||
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
|
||||
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
|
||||
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
|
||||
assert!(!loaded.toolbar);
|
||||
assert!(loaded.docking.is_none());
|
||||
assert!(!loaded.docking_hidden.contains_key("layers"));
|
||||
if invalid_hidden.is_object() {
|
||||
assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location);
|
||||
} else {
|
||||
assert!(loaded.docking_hidden.is_empty());
|
||||
}
|
||||
assert_eq!(loaded.custom_workspaces.len(), 2);
|
||||
assert_eq!(loaded.custom_workspaces[0].name, "Future");
|
||||
assert!(loaded.custom_workspaces[0].docking.is_none());
|
||||
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
|
||||
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
|
||||
}
|
||||
}
|
||||
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(roundtrip).unwrap(), original);
|
||||
assert!(
|
||||
decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(),
|
||||
"unrelated fields retain normal strict parsing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// Preferences › User Interface › System Title Bar gives the window back its system
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[path = "profile.rs"]
|
||||
mod profile;
|
||||
|
||||
/// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application
|
||||
/// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and
|
||||
/// BSD).
|
||||
pub fn prefs_path_for(name: &str, lower: &str) -> Option<PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join(lower))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
let legacy = name == "DrawCraft" || lower == "drawcraft";
|
||||
profile::prefs_path(legacy)
|
||||
}
|
||||
|
||||
/// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn prefs_path(legacy: bool) -> Option<PathBuf> {
|
||||
prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key))
|
||||
}
|
||||
|
||||
fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
|
||||
if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") {
|
||||
// Never fall back to DrawCraft's real profile when the isolated file does not exist.
|
||||
return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json"));
|
||||
}
|
||||
let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") };
|
||||
let base = match os {
|
||||
"macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)),
|
||||
"windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)),
|
||||
_ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)),
|
||||
};
|
||||
base.map(|p| p.join("ui.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn environment(key: &str) -> Option<OsString> {
|
||||
match key {
|
||||
"HOME" => Some("/user".into()),
|
||||
"APPDATA" => Some("/roaming".into()),
|
||||
"XDG_CONFIG_HOME" => Some("/xdg-config".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_profile_has_no_legacy_fallback_on_any_platform() {
|
||||
for os in ["macos", "windows", "linux"] {
|
||||
let env = |key: &str| {
|
||||
if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }
|
||||
};
|
||||
assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into()));
|
||||
assert_eq!(prefs_path_with(os, true, env), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_override_does_not_fall_back_to_user_files() {
|
||||
let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) };
|
||||
assert_eq!(prefs_path_with("macos", false, env), None);
|
||||
assert_eq!(prefs_path_with("macos", true, env), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unset_override_keeps_platform_paths_and_legacy_migration() {
|
||||
assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into()));
|
||||
assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into()));
|
||||
assert_eq!(
|
||||
prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }),
|
||||
Some("/user/.config/drawcraft/ui.json".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -219,11 +219,13 @@ fn weighted_stroke(d: &mut Document, id: NodeId) -> Result<&mut vectorcraft_doc:
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
/// The `index` param, checked against the points of `prof`.
|
||||
/// The `index` param (none when absent or null), checked against the points of `prof`: a malformed
|
||||
/// one is refused like one out of range, never wrapped round or taken as missing.
|
||||
fn point_index(p: &Value, prof: &WidthProfile, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get("index").and_then(Value::as_u64).map(|v| v as usize) {
|
||||
Some(i) if i >= prof.points.len() => Err(bad(cmd, "no such width point")),
|
||||
i => Ok(i),
|
||||
let Some(v) = p.get("index").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
match point_indices(&json!([v])).and_then(|i| i.first().copied()) {
|
||||
Some(i) if i < prof.points.len() => Ok(Some(i)),
|
||||
_ => Err(bad(cmd, "no such width point")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,11 +325,13 @@ fn width_point_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn width_point_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthPoint.remove";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let mut indices: Vec<usize> = match (p.get("indices").and_then(Value::as_array), p.get("index").and_then(Value::as_u64)) {
|
||||
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect(),
|
||||
(None, Some(i)) => vec![i as usize],
|
||||
let indices = match (p.get("indices"), p.get("index")) {
|
||||
(Some(a), _) => point_indices(a),
|
||||
(None, Some(i)) => point_indices(&json!([i])),
|
||||
(None, None) => return Err(bad(C, "missing index")),
|
||||
};
|
||||
// A malformed index fails the whole delete instead of being dropped while the others go.
|
||||
let mut indices = indices.ok_or_else(|| bad(C, "no such width point"))?;
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
s.edit("Delete Width Point", |d, _| {
|
||||
|
||||
@@ -148,9 +148,12 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String>
|
||||
point_fields(pv, &mut pt, Affine::IDENTITY).map_err(|e| format!("freeform point {i}: {e}"))?;
|
||||
f.points.push(pt);
|
||||
}
|
||||
for l in v.get("lines").and_then(Value::as_array).into_iter().flatten() {
|
||||
let ix = l.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()).unwrap_or_default();
|
||||
f.add_line(ix)?;
|
||||
if let Some(lines) = v.get("lines") {
|
||||
let lines = lines.as_array().ok_or("`freeform.lines` must be an array")?;
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let ix = point_indices(l).ok_or_else(|| format!("freeform line {i} must be an array of point indices"))?;
|
||||
f.add_line(ix).map_err(|e| format!("freeform line {i}: {e}"))?;
|
||||
}
|
||||
}
|
||||
if let Some(m) = str_param(v, "mode") {
|
||||
f.mode = parse_mode(m)?;
|
||||
@@ -279,11 +282,7 @@ fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn add_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.freeform.addLine";
|
||||
let points: Vec<usize> = p
|
||||
.get("points")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| a.iter().map(|v| v.as_u64().map(|i| i as usize)).collect())
|
||||
.ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
|
||||
let points = p.get("points").and_then(point_indices).ok_or_else(|| bad(C, "`points` must be an array of point indices"))?;
|
||||
let line = edit(s, p, C, |f, _| f.add_line(points.clone()))?;
|
||||
Ok(json!({ "line": line }))
|
||||
}
|
||||
@@ -323,3 +322,24 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
v["selected"] = json!(selected);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A line with an index that isn't a point's is refused, not shortened to the indices that are.
|
||||
#[test]
|
||||
fn malformed_freeform_lines_are_not_silently_shortened() {
|
||||
let points = json!([
|
||||
{"at": [0, 0], "color": "#ff0000"},
|
||||
{"at": [50, 0], "color": "#00ff00"},
|
||||
{"at": [100, 0], "color": "#0000ff"}
|
||||
]);
|
||||
for lines in [json!([[0, "broken", 2]]), json!([[0, -1, 2]]), json!([[0, 1.5, 2]]), json!([{"points": [0, 1]}]), json!("not an array")] {
|
||||
let value = json!({"points": points, "lines": lines, "mode": "lines"});
|
||||
assert!(parse_freeform(&value).is_err(), "invalid lines accepted: {value}");
|
||||
}
|
||||
let good = parse_freeform(&json!({"points": points, "lines": [[0, 1, 2]], "mode": "lines"})).unwrap();
|
||||
assert_eq!(good.lines, vec![vec![0, 1, 2]]);
|
||||
}
|
||||
}
|
||||
@@ -316,6 +316,11 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str
|
||||
values.iter().map(|value| checked_id(s, value, command)).collect()
|
||||
}
|
||||
|
||||
/// The objects a command acts on: `ids` when given (every one checked), else the selected objects.
|
||||
pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", command) } else { edit::selected_roots(s) }
|
||||
}
|
||||
|
||||
pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result<NodeId> {
|
||||
let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?;
|
||||
if s.doc()?.doc.node(id).is_none() {
|
||||
@@ -335,6 +340,12 @@ pub(crate) fn finite_numbers<const N: usize>(v: &Value) -> Option<[f64; N]> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `v` as a list of point indices: `None` unless it is an array of non-negative integers, so no
|
||||
/// malformed index is dropped or wrapped round.
|
||||
pub(crate) fn point_indices(v: &Value) -> Option<Vec<usize>> {
|
||||
v.as_array()?.iter().map(|i| usize::try_from(i.as_u64()?).ok()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
|
||||
@@ -976,13 +976,22 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the selected root (one of `ids`) that is the key object or contains it, such as the
|
||||
/// compound path or selected group the key is in. Align to the key object and Distribute Spacing
|
||||
/// with a spacing keep it in place.
|
||||
fn key_root(s: &Session, ids: &[NodeId]) -> Option<NodeId> {
|
||||
let st = s.doc().ok()?;
|
||||
let ancestry = st.doc.ancestry(st.selection.key?)?;
|
||||
ids.iter().copied().find(|id| ancestry.contains(id))
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let preview = preview_bounds(s, p, "object.align")?;
|
||||
let r = reference_rect(s, p, &ids, preview)?;
|
||||
let h = str_param(p, "horizontal");
|
||||
let v = str_param(p, "vertical");
|
||||
let key = s.doc()?.selection.key;
|
||||
let key = key_root(s, &ids);
|
||||
let moves: Vec<(NodeId, Vec2)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
items_bounds(d, &ids, preview)
|
||||
@@ -1100,9 +1109,9 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
deltas.push((*id, pos - start(r)));
|
||||
pos += size(r) + gap;
|
||||
}
|
||||
// With a spacing (aligning to a key object) the key object stays where it is: the others
|
||||
// are spaced from it.
|
||||
let key = s.doc()?.selection.key;
|
||||
// With a spacing (aligning to a key object) the key object stays where it is, with the
|
||||
// compound path or selected group it is in: the others are spaced from it.
|
||||
let key = key_root(s, &ids);
|
||||
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
|
||||
let moves: Vec<(NodeId, Vec2)> =
|
||||
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -407,6 +407,9 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
let item = item_target(s, p, cmd)?.of_kind(s, fill);
|
||||
let selected = item.targets(s, p)?;
|
||||
let ids = super::graph::paint_targets(&s.doc()?.doc, &selected);
|
||||
// The gradient mesh point last clicked with the Mesh tool or Direct Selection: a solid fill
|
||||
// recolours just that point of its mesh.
|
||||
let mesh_point = if fill && matches!(paint, Paint::Solid { .. }) { focused_mesh_point(s) } else { None };
|
||||
let label = if fill { "Fill Color" } else { "Stroke Color" };
|
||||
edit_items_then(
|
||||
s,
|
||||
@@ -416,6 +419,15 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
label,
|
||||
fill,
|
||||
|n, index| {
|
||||
if index.is_none()
|
||||
&& let Paint::Solid { color, .. } = &paint
|
||||
&& let Some((_, i)) = mesh_point.filter(|(id, _)| *id == n.id)
|
||||
&& let NodeKind::Mesh(m) = &mut n.kind
|
||||
&& let Some(q) = m.points.get_mut(i)
|
||||
{
|
||||
q.color = *color;
|
||||
return Ok(());
|
||||
}
|
||||
if index.is_none()
|
||||
&& let NodeKind::Text(t) = &mut n.kind
|
||||
{
|
||||
@@ -457,6 +469,14 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result<V
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The mesh point the active tool has focused (its `meshPoint` option: the Mesh tool's or Direct
|
||||
/// Selection's point last clicked).
|
||||
fn focused_mesh_point(s: &Session) -> Option<(NodeId, usize)> {
|
||||
let o = s.tool_options();
|
||||
let pt = o.get("meshPoint")?;
|
||||
Some((NodeId(pt.get("id")?.as_u64()?), usize::try_from(pt.get("index")?.as_u64()?).ok()?))
|
||||
}
|
||||
|
||||
/// A type run's stroke; a painted stroke on a run without one gets 1 pt.
|
||||
fn set_run_stroke(style: &mut CharStyle, paint: Paint) {
|
||||
if !paint.is_none() {
|
||||
|
||||
@@ -500,7 +500,7 @@ pub(crate) fn refresh(before: &Document, d: &mut Document) -> Result<()> {
|
||||
fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let area = points.iter().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| b.union_pt(*p)).inflate(tolerance, tolerance);
|
||||
let supplied = p.get("ids").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_u64).map(NodeId).collect::<Vec<_>>());
|
||||
let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?;
|
||||
let expand = supplied.is_none() && st.selection.objects.len() < 2;
|
||||
let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() });
|
||||
let eligible: Vec<NodeId> = ids
|
||||
|
||||
@@ -8,7 +8,6 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
|
||||
use vectorcraft_geom::{PathData, shapes};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
|
||||
@@ -36,19 +35,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match p.get("ids").and_then(Value::as_array) {
|
||||
Some(a) => Ok(a.iter().filter_map(Value::as_u64).map(NodeId).collect()),
|
||||
None => selected_roots(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_param(p: &Value, base: TextWrap) -> TextWrap {
|
||||
TextWrap { offset: f64_or(p, "offset", base.offset).clamp(-1000.0, 1000.0), invert: bool_or(p, "invert", base.invert) }
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let ids = checked_ids_or_selection(s, p, "object.textWrap.make")?;
|
||||
let w = wrap_param(p, TextWrap::default());
|
||||
s.edit("Make Text Wrap", |d, _| {
|
||||
for id in &ids {
|
||||
@@ -66,7 +58,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
targets(s, p)?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
|
||||
checked_ids_or_selection(s, p, "object.textWrap.release")?.into_iter().filter(|i| d.node(*i).is_some_and(|n| n.wrap.is_some())).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("Text Wrap: select a wrap object".into()));
|
||||
@@ -83,7 +75,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let ids = checked_ids_or_selection(s, p, "object.textWrap.options")?;
|
||||
let current = {
|
||||
let d = &s.doc()?.doc;
|
||||
ids.iter().find_map(|i| d.node(*i).and_then(|n| n.wrap)).unwrap_or_default()
|
||||
@@ -212,6 +204,35 @@ mod tests {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).lines.iter().map(|l| l.avail).collect()
|
||||
}
|
||||
|
||||
/// Invalid explicit ids must not turn a requested multi-object edit into a partial one.
|
||||
#[test]
|
||||
fn text_wrap_commands_reject_malformed_target_lists_without_editing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
let other = s.execute("shape.rectangle", &json!({"x": 120, "y": 10, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
for command in ["object.textWrap.make", "object.textWrap.release", "object.textWrap.options"] {
|
||||
for ids in [json!([id, "invalid"]), json!([id, 99999]), json!({"not": "an array"})] {
|
||||
let e = s.execute(command, &json!({"ids": ids, "offset": 12})).unwrap_err().to_string();
|
||||
assert!(e.contains("id") || e.contains("array"), "{command}: {e}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{command} added history on error");
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
s.execute("object.textWrap.make", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_some());
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(other)).unwrap().wrap.is_none());
|
||||
s.execute("object.textWrap.options", &json!({"ids": [id], "offset": 12})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.unwrap().offset, 12.0);
|
||||
s.execute("object.textWrap.release", &json!({"ids": [id]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(NodeId(id)).unwrap().wrap.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_wraps_around_objects_above_and_follows_edits() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -49,11 +49,6 @@ fn names_param(p: &Value, cmd: &str, fallback: Option<String>, nothing: &str) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects a bind acts on: `ids` when given (every one checked), else the selection.
|
||||
fn target_ids(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { checked_ids_param(s, p, "ids", cmd) } else { selected_roots(s) }
|
||||
}
|
||||
|
||||
/// Refuse what a variable of `kind` can't drive: art that is locked or on a hidden or locked
|
||||
/// layer is not the document's to rewrite, and a text variable only drives type.
|
||||
fn check_bindable(doc: &vectorcraft_doc::Document, ids: &[NodeId], kind: VariableKind, cmd: &str) -> Result<()> {
|
||||
@@ -203,7 +198,7 @@ fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.bind";
|
||||
let name = named(str_param(p, "variable"), "variable", C)?;
|
||||
let ids = target_ids(s, p, C)?;
|
||||
let ids = checked_ids_or_selection(s, p, C)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "nothing to bind"));
|
||||
}
|
||||
@@ -218,7 +213,7 @@ fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn unbind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "variable.unbind";
|
||||
let ids = target_ids(s, p, C)?;
|
||||
let ids = checked_ids_or_selection(s, p, C)?;
|
||||
let name = defined_variable(s, p, C)?;
|
||||
let n = s.edit("Unbind Variable", |d, _| {
|
||||
let mut n = 0;
|
||||
|
||||
@@ -55,12 +55,16 @@ fn list_views(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "view.saved.new";
|
||||
let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let center = center.ok_or_else(|| bad(C, "missing center [x, y]"))?;
|
||||
let [x, y] = p.get("center").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "center must be [x, y]"))?;
|
||||
let zoom = f64_req(p, "zoom", C)?;
|
||||
if !(zoom.is_finite() && zoom > 0.0) {
|
||||
return Err(bad(C, "zoom must be positive"));
|
||||
}
|
||||
// Given, the rotation is a number: nothing malformed falls back to 0°.
|
||||
let rotation = match p.get("rotation") {
|
||||
Some(r) => r.as_f64().filter(|r| r.is_finite()).ok_or_else(|| bad(C, "rotation must be a number of degrees"))?,
|
||||
None => 0.0,
|
||||
};
|
||||
let st = s.doc()?;
|
||||
if st.doc.views.len() >= MAX_VIEWS {
|
||||
return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views")));
|
||||
@@ -72,7 +76,7 @@ fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.views.iter().any(|v| v.name == name) {
|
||||
return Err(bad(C, format!("a view named `{name}` exists")));
|
||||
}
|
||||
let view = SavedView { name: name.clone(), center, zoom, rotation: f64_or(p, "rotation", 0.0) };
|
||||
let view = SavedView { name: name.clone(), center: Point::new(x, y), zoom, rotation };
|
||||
let index = s.edit("New View", |d, _| {
|
||||
d.views.push(view);
|
||||
Ok(d.views.len() - 1)
|
||||
@@ -108,6 +112,29 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Malformed dimensions are rejected rather than saving an arbitrary or partial viewport.
|
||||
#[test]
|
||||
fn saved_views_require_exact_finite_coordinates_and_rotation() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
for params in [
|
||||
json!({"center": [10], "zoom": 2}),
|
||||
json!({"center": [10, 20, 30], "zoom": 2}),
|
||||
json!({"center": [10, "bad"], "zoom": 2}),
|
||||
json!({"center": [10, 20], "zoom": 2, "rotation": "bad"}),
|
||||
json!({"center": [10, 20], "zoom": 2, "rotation": null}),
|
||||
] {
|
||||
assert!(s.execute("view.saved.new", ¶ms).is_err(), "accepted {params}");
|
||||
assert!(s.doc().unwrap().doc.views.is_empty());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before);
|
||||
}
|
||||
s.execute("view.saved.new", &json!({"center": [10, 20], "zoom": 2, "rotation": -15})).unwrap();
|
||||
let views = &s.doc().unwrap().doc.views;
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].rotation, -15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_rename_delete_views() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -52,6 +52,67 @@ fn group_ungroup() {
|
||||
assert_eq!(s.doc().unwrap().selection.len(), 2);
|
||||
}
|
||||
|
||||
/// Makes 10 pt squares at (0, 90) and (50, 100) into a compound path or group with the commands in
|
||||
/// `make`, each run on the selection (the last one makes `outer`), draws a 10 pt square `r` at
|
||||
/// (200, 0), selects the second square and `r`, and sets the selection's `key` to that square
|
||||
/// → (session, key, outer, r).
|
||||
fn key_inside(make: &[&str]) -> (Session, NodeId, NodeId, NodeId) {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 90.0, 10.0, 10.0);
|
||||
let key = rect(&mut s, 50.0, 100.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, key.0]})).unwrap();
|
||||
let mut outer = key;
|
||||
for cmd in make {
|
||||
outer = NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
}
|
||||
let r = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
s.execute("select.set", &json!({"ids": [key.0, r.0]})).unwrap();
|
||||
s.select(|_, sel| sel.key = Some(key)).unwrap();
|
||||
(s, key, outer, r)
|
||||
}
|
||||
|
||||
/// Aligns the selection left to its key object and, after Undo, spaces it 5 pt apart vertically
|
||||
/// → the top-left corners of `outer` and `r` after each.
|
||||
fn aligned_then_spaced(s: &mut Session, outer: NodeId, r: NodeId) -> [((f64, f64), (f64, f64)); 2] {
|
||||
let at = |s: &Session, id| {
|
||||
let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
(b.x0, b.y0)
|
||||
};
|
||||
s.execute("object.align", &json!({"horizontal": "left", "bounds": "geometric"})).unwrap();
|
||||
let aligned = (at(s, outer), at(s, r));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 5, "bounds": "geometric"})).unwrap();
|
||||
[aligned, (at(s, outer), at(s, r))]
|
||||
}
|
||||
|
||||
/// Align and Distribute Spacing move a compound path as one object. With one of its members as the
|
||||
/// key object, the compound path stays where it is: Align moves the others to the member's edge,
|
||||
/// and Distribute Spacing spaces them from the compound path.
|
||||
#[test]
|
||||
fn a_compound_path_that_contains_the_key_object_stays_in_place() {
|
||||
let (mut s, key, outer, r) = key_inside(&["object.compoundPath.make"]);
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(key));
|
||||
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
|
||||
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "it stays, the other moved to the key");
|
||||
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "5 pt apart, it stays");
|
||||
}
|
||||
|
||||
/// The key object stays the key when the group that contains it is added to the selection, by
|
||||
/// `select.add` or by the Group Selection tool's click on a selected member. Align and Distribute
|
||||
/// Spacing move that group as one object, and the group stays where it is, also when the key is in
|
||||
/// a group inside it.
|
||||
#[test]
|
||||
fn a_selected_group_that_contains_the_key_object_stays_in_place() {
|
||||
for make in [&["object.group"][..], &["object.group", "object.group"]] {
|
||||
let (mut s, key, outer, r) = key_inside(make);
|
||||
s.execute("select.add", &json!({"ids": [outer.0]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.key, Some(key), "{make:?}");
|
||||
let [aligned, spaced] = aligned_then_spaced(&mut s, outer, r);
|
||||
assert_eq!(aligned, ((0.0, 90.0), (50.0, 0.0)), "{make:?}: it stays, the other moved to the key");
|
||||
assert_eq!(spaced, ((0.0, 90.0), (200.0, 75.0)), "{make:?}: 5 pt apart, it stays");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_order() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::Node;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
use crate::tooling::ViewInfo;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -99,6 +101,38 @@ fn edit_colors_reach_mesh_points() {
|
||||
assert_eq!(mesh_colors(&s, m), pts, "a mesh's points are fill colours");
|
||||
}
|
||||
|
||||
/// Clicking a gradient mesh's point with the Mesh tool or Direct Selection picks it: a solid fill
|
||||
/// colour then recolours that point alone (#1050).
|
||||
#[test]
|
||||
fn a_clicked_mesh_point_takes_the_fill_colour() {
|
||||
let mut s = session();
|
||||
let m = mesh(&mut s);
|
||||
let before = mesh_colors(&s, m);
|
||||
let v = ViewInfo::default();
|
||||
let click = |s: &mut Session, i: usize| {
|
||||
let NodeKind::Mesh(g) = &s.doc().unwrap().doc.node(m).unwrap().kind else { panic!("not a mesh") };
|
||||
let q = g.points[i].p;
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, q.x, q.y), v).unwrap();
|
||||
}
|
||||
};
|
||||
// The Mesh tool selects the mesh whose point it clicks.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.select_tool("mesh", v).unwrap();
|
||||
click(&mut s, 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [m]);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
let after = mesh_colors(&s, m);
|
||||
assert_eq!(after[1].to_hex(), "#0000ff", "the clicked point takes the colour");
|
||||
assert_eq!((after[0], after[2], after[3]), (before[0], before[2], before[3]), "the other points keep theirs");
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
click(&mut s, 2);
|
||||
s.execute("paint.setFill", &json!({"color": "#ffff00"})).unwrap();
|
||||
let after = mesh_colors(&s, m);
|
||||
assert_eq!((after[1].to_hex(), after[2].to_hex()), ("#0000ff".to_string(), "#ffff00".to_string()));
|
||||
assert_eq!((after[0], after[3]), (before[0], before[3]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_moves_a_mesh_to_the_blended_colour() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -42,6 +42,22 @@ fn stroke(s: &Session, id: NodeId, x: f64, y: f64) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// An invalid explicit Shaper target must never silently change which shapes get edited.
|
||||
#[test]
|
||||
fn shaper_scribble_rejects_malformed_explicit_targets() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let untouched = s.doc().unwrap().doc.layers.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for ids in [json!([a.0, "bad"]), json!([a.0, 999999]), json!({"id": a.0}), json!("bad")] {
|
||||
let err = s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": ids}));
|
||||
assert!(err.is_err(), "malformed targets were accepted");
|
||||
assert_eq!(s.doc().unwrap().doc.layers, untouched);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
s.execute("shaper.scribble", &json!({"points": [[25, 20], [35, 30]], "ids": [a.0]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_merges_with_origin_color_keeps_live_originals_and_undoes_once() {
|
||||
for reverse in [false, true] {
|
||||
|
||||
@@ -30,6 +30,51 @@ fn dark(s: &Session, x: u32, y: u32) -> bool {
|
||||
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(x, y)[0] < 128
|
||||
}
|
||||
|
||||
/// Invalid entries in an explicit multi-point delete must not delete the valid entries.
|
||||
#[test]
|
||||
fn invalid_width_point_indices_leave_the_entire_profile_unchanged() {
|
||||
let (mut s, id) = line_session();
|
||||
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
|
||||
let before = points(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
for bad in [
|
||||
json!({"indices": [1, "bad"]}),
|
||||
json!({"indices": [-1, 1]}),
|
||||
json!({"indices": [1.5, 1]}),
|
||||
json!({"indices": {"point": 1}}),
|
||||
json!({"index": "bad"}),
|
||||
] {
|
||||
let mut params = bad.as_object().unwrap().clone();
|
||||
params.insert("id".into(), json!(id.0));
|
||||
let err = s.execute("stroke.widthPoint.remove", &serde_json::Value::Object(params));
|
||||
assert!(err.is_err(), "accepted malformed width point indices");
|
||||
assert_eq!(points(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
}
|
||||
s.execute("stroke.widthPoint.remove", &json!({"id": id.0, "indices": [1]})).unwrap();
|
||||
assert_eq!(points(&s, id).len(), before.len() - 1);
|
||||
}
|
||||
|
||||
/// Setting or copying a width point with a malformed `index` is refused like one out of range: it
|
||||
/// never adds a point instead, or wraps a huge index round. A null `index` is no index (a new point).
|
||||
#[test]
|
||||
fn a_malformed_width_point_index_is_refused_by_set_and_copy() {
|
||||
let (mut s, id) = line_session();
|
||||
set(&mut s, json!({"id": id.0, "t": 0.5, "left": 10, "right": 10}));
|
||||
let before = points(&s, id);
|
||||
let history = s.doc().unwrap().history.undo.len();
|
||||
for index in [json!("bad"), json!(-1), json!(1.5), json!([1]), json!(u64::MAX), json!(before.len())] {
|
||||
for cmd in ["stroke.widthPoint.set", "stroke.widthPoint.copy"] {
|
||||
let r = s.execute(cmd, &json!({"id": id.0, "index": index, "t": 0.3, "left": 4, "right": 4}));
|
||||
assert!(r.is_err(), "{cmd} took index {index}");
|
||||
assert_eq!(points(&s, id), before, "{cmd} with index {index}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), history);
|
||||
set(&mut s, json!({"id": id.0, "index": null, "t": 0.3, "left": 4, "right": 4}));
|
||||
assert_eq!(points(&s, id).len(), before.len() + 1, "a null index adds a point");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() {
|
||||
let (mut s, id) = line_session();
|
||||
|
||||
@@ -81,23 +81,6 @@ fn every_command_without_a_document() {
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Known bugs (see the `#[ignore]` repro tests below): (command, param key) pairs skipped by the
|
||||
/// junk sweep so the rest of the sweep stays meaningful.
|
||||
const KNOWN_BUGS: &[(&str, &str)] = &[
|
||||
("object.scale", "sx"),
|
||||
("object.transformEach", "scaleH"),
|
||||
("object.transformEach", "scaleV"),
|
||||
("object.distributeSpacing", "spacing"),
|
||||
("artboard.setProps", "x"),
|
||||
("artboard.setProps", "y"),
|
||||
("artboard.setProps", "width"),
|
||||
("artboard.setProps", "height"),
|
||||
];
|
||||
|
||||
fn known_bug(id: &str, p: &Value) -> bool {
|
||||
KNOWN_BUGS.iter().any(|(c, k)| *c == id && p.get(*k).is_some())
|
||||
}
|
||||
|
||||
/// Regression: huge column counts used to panic with `capacity overflow`; in every layout and
|
||||
/// order (#681), with huge and negative spacing too.
|
||||
#[test]
|
||||
@@ -157,9 +140,6 @@ fn every_command_with_junk_params() {
|
||||
cases.truncate(12);
|
||||
}
|
||||
for p in &cases {
|
||||
if known_bug(c.id, p) {
|
||||
continue;
|
||||
}
|
||||
calls += 1;
|
||||
if let Some(f) = probe(fx, c.id, p) {
|
||||
failures.push(f);
|
||||
@@ -494,9 +474,6 @@ fn fuzzed_calls_keep_documents_serializable() {
|
||||
let k = if k == "path" { continue } else { k };
|
||||
let mut s = Fixture::Multi.session();
|
||||
let p = json!({ k.clone(): v });
|
||||
if known_bug(c.id, &p) {
|
||||
continue;
|
||||
}
|
||||
let ok = catch_quiet(|| s.execute(c.id, &p)).map(|r| r.is_ok()).unwrap_or(false);
|
||||
if ok && let Err(e) = catch_quiet(|| check_all(&mut s)).unwrap_or_else(|m| Err(format!("panic in checks: {m}"))) {
|
||||
failures.push(format!("{} {p}: {e}", c.id));
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::io::Read as _;
|
||||
use super::lex::find;
|
||||
use crate::ps;
|
||||
|
||||
pub use read::{Structure, read};
|
||||
pub use read::{Structure, artboards, read};
|
||||
pub(crate) use read::{slot_frame, slot_of};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -106,6 +106,15 @@ pub fn read(data: &[u8]) -> Result<Structure, String> {
|
||||
r.finish()
|
||||
}
|
||||
|
||||
/// The artboards (art space) of the editing data `data` that [`read`] can't read as a whole, as far
|
||||
/// as it gets: where to put the pages of its PDF part instead (#1068).
|
||||
pub fn artboards(data: &[u8]) -> Vec<Rect> {
|
||||
let mut r = Reader::new(data);
|
||||
// Artboards read before an error still say where they are.
|
||||
let _ = r.run();
|
||||
r.artboards.into_iter().map(|(_, rect)| rect).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct GState {
|
||||
fill: Paint,
|
||||
|
||||
@@ -339,6 +339,12 @@ fn turn(a: f64, b: f64) -> f64 {
|
||||
if d > std::f64::consts::PI { d - std::f64::consts::TAU } else { d }
|
||||
}
|
||||
|
||||
/// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left
|
||||
/// of each after whole turns.
|
||||
fn ccw_turn(a: f64, b: f64) -> f64 {
|
||||
(b % 360.0 - a % 360.0).rem_euclid(360.0)
|
||||
}
|
||||
|
||||
impl Interp<'_> {
|
||||
/// User space → document space.
|
||||
pub fn xf(&self) -> Affine {
|
||||
@@ -436,18 +442,19 @@ impl Interp<'_> {
|
||||
|
||||
/// `arc` / `arcn`: from angle `a1` to `a2` (degrees, counter-clockwise or clockwise).
|
||||
fn arc(&mut self, c: Point, r: f64, a1: f64, a2: f64, ccw: bool) -> Res {
|
||||
let (a1, mut a2) = (a1.to_radians(), a2.to_radians());
|
||||
let tau = std::f64::consts::TAU;
|
||||
if ccw {
|
||||
while a2 < a1 {
|
||||
a2 += tau;
|
||||
}
|
||||
// `arc` raises `a2` by multiples of 360 until it is at least `a1`, and `arcn` lowers it
|
||||
// until it is at most `a1` (PLRM 3rd ed., `arc` and `arcn` in chapter 8). Those sweeps,
|
||||
// and the first endpoint, are worked out from what is left of each angle after whole turns.
|
||||
let sweep = if ccw && a2 < a1 {
|
||||
ccw_turn(a1, a2)
|
||||
} else if !ccw && a2 > a1 {
|
||||
-ccw_turn(a2, a1)
|
||||
} else {
|
||||
while a2 > a1 {
|
||||
a2 -= tau;
|
||||
}
|
||||
}
|
||||
let sweep = (a2 - a1).clamp(-tau * 4.0, tau * 4.0);
|
||||
a2 - a1
|
||||
};
|
||||
let tau = std::f64::consts::TAU;
|
||||
let sweep = sweep.to_radians().clamp(-tau * 4.0, tau * 4.0);
|
||||
let a1 = (a1 % 360.0).to_radians();
|
||||
let start = c + Vec2::new(r * a1.cos(), r * a1.sin());
|
||||
self.append_user(std::iter::once(PathEl::MoveTo(start)))?;
|
||||
let arc = kurbo::Arc::new(c, Vec2::new(r, r), a1, sweep, 0.0);
|
||||
|
||||
@@ -1041,11 +1041,53 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl
|
||||
Err(why) => {
|
||||
let mut all = warnings;
|
||||
all.push(format!("{LAYERS_UNREAD} from its editing data ({why}): they come from its PDF part"));
|
||||
let mut visible = visible;
|
||||
place_pages(&ai::artboards(&data), &mut visible);
|
||||
(visible, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pages of `doc` (a `.ai` read as its PDF part: a page per artboard, in a row) moved, with
|
||||
/// the art on each, to where `boards` (the artboards of its editing data, in art space) are, so
|
||||
/// the artboards keep their layout when the layers can't be read (#1068). Left as they are unless
|
||||
/// there are as many artboards as pages, of the same sizes.
|
||||
fn place_pages(boards: &[Rect], doc: &mut Document) {
|
||||
let (Some(first), true) = (boards.first(), boards.len() == doc.artboards.len() && boards.len() > 1) else { return };
|
||||
if boards.iter().zip(&doc.artboards).any(|(b, a)| (b.width() - a.rect.width()).abs() > 0.5 || (b.height() - a.rect.height()).abs() > 0.5) {
|
||||
return;
|
||||
}
|
||||
// Art space (y up) → the document, the first artboard's top left at the origin, as `read` has it.
|
||||
let to_doc = Affine::new([1.0, 0.0, 0.0, -1.0, -first.x0, first.y1]);
|
||||
let pages: Vec<Rect> = doc.artboards.iter().map(|a| a.rect).collect();
|
||||
let moves: Vec<Vec2> = boards.iter().zip(&pages).map(|(b, page)| to_doc.transform_rect_bbox(*b).origin() - page.origin()).collect();
|
||||
for layer in &mut doc.layers {
|
||||
place_art(Arc::make_mut(layer), &pages, &moves);
|
||||
}
|
||||
for (a, v) in doc.artboards.iter_mut().zip(&moves) {
|
||||
a.rect = a.rect + *v;
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects in `layer` (and its sublayers) moved by the move of the page they are on.
|
||||
fn place_art(layer: &mut Node, pages: &[Rect], moves: &[Vec2]) {
|
||||
let Some(children) = layer.children_mut() else { return };
|
||||
for child in children {
|
||||
if matches!(child.kind, NodeKind::Layer { .. }) {
|
||||
place_art(Arc::make_mut(child), pages, moves);
|
||||
continue;
|
||||
}
|
||||
let Some(r) = child.visual_bounds() else { continue };
|
||||
// The page it is on: the one with most of it (pages keep only the art that reaches into
|
||||
// them), else the nearest.
|
||||
let fit = |p: &Rect| (p.intersect(r).area().max(0.0), -(p.center() - r.center()).hypot());
|
||||
let Some((i, _)) = pages.iter().map(fit).enumerate().max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1))) else { continue };
|
||||
if let Some(v) = moves.get(i).filter(|v| **v != Vec2::ZERO) {
|
||||
Arc::make_mut(child).transform(Affine::translate(*v), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `.ai` saved without its PDF part (its pages show only a placeholder) from its editing copy
|
||||
/// alone (`private`, the joined `AIPrivateData` streams) → the document and the import's notes.
|
||||
pub fn ai_alone(private: &[u8]) -> Result<(Document, Vec<String>), String> {
|
||||
|
||||
@@ -640,6 +640,42 @@ fn hostile_executable_files_and_type_loops_are_refused() {
|
||||
blue("currentfile cvx exec currentfile flushfile 0 0 1 setrgbcolor");
|
||||
}
|
||||
|
||||
/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes
|
||||
/// more than 20 s.
|
||||
fn read_soon(body: &str) -> crate::Imported {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let ps = eps(body);
|
||||
std::thread::spawn(move || tx.send(import(&ps)).ok());
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap()
|
||||
}
|
||||
|
||||
/// `arc` raises its second angle by multiples of 360 until it is at least the first, and `arcn`
|
||||
/// lowers it until it is at most the first (PLRM 3rd ed., `arc` and `arcn` in chapter 8), however
|
||||
/// large the angles: the arc runs between the angles' remainders after whole turns and ends at
|
||||
/// the second angle, the new current point.
|
||||
#[test]
|
||||
fn arcs_with_huge_angles_end_at_their_second_angle() {
|
||||
// `at(deg)` is the point at `deg` degrees on the circle of radius 10 around (50, 50), in
|
||||
// document space.
|
||||
let at = |deg: f64| Point::new(50.0 + 10.0 * deg.to_radians().cos(), 50.0 - 10.0 * deg.to_radians().sin());
|
||||
// Each program is listed with the remainders of its first and second angles after whole turns.
|
||||
for (body, first, second) in [
|
||||
("50 50 10 1e18 0 arc", 280.0, 0.0),
|
||||
("50 50 10 1e308 0 arc", 296.0, 0.0),
|
||||
("50 50 10 0 1e18 arcn", 0.0, 280.0),
|
||||
("50 50 10 0 1e308 arcn", 0.0, 296.0),
|
||||
] {
|
||||
let r = read_soon(&format!("newpath {body} currentpoint stroke 1 1 rectfill"));
|
||||
let o = objects(&r.document);
|
||||
assert_eq!(o.len(), 2, "{body}: {:?}", r.warnings);
|
||||
// The arc lies within one quarter of the circle, so its endpoints span its bounds.
|
||||
let (a, b) = (at(first), at(second));
|
||||
assert!(near(bounds(&o[0]), Rect::from_points(a, b)), "{body}: {:?}", bounds(&o[0]));
|
||||
// The second object is the square drawn at the current point.
|
||||
assert!(near(bounds(&o[1]), Rect::new(b.x, b.y - 1.0, b.x + 1.0, b.y)), "{body}: {:?}", bounds(&o[1]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cshow_runs_its_procedure_for_each_character() {
|
||||
check("/n 0 def { pop pop pop /n n 1 add def } (abc) cshow n 3 eq");
|
||||
|
||||
@@ -237,6 +237,49 @@ fn an_ai_file_that_cant_be_read_through_its_editing_data_keeps_its_pdf_part() {
|
||||
assert!(w.iter().any(|w| w.contains("different page")), "{w:?}");
|
||||
}
|
||||
|
||||
/// A `.ai` whose layers can't be read comes in as its PDF part, a page per artboard in a row; its
|
||||
/// pages still go where its editing data puts its artboards, with their art (#1068).
|
||||
#[test]
|
||||
fn an_ai_file_read_as_its_pdf_part_keeps_its_artboard_layout() {
|
||||
// Two 100-point artboards, the second below the first (art space, y up).
|
||||
let board = |x0: i32, y0: i32| {
|
||||
format!(
|
||||
"%_/Dictionary :\n%_{x0} {} /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} {y0} /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n",
|
||||
y0 + 100,
|
||||
x0 + 100
|
||||
)
|
||||
};
|
||||
let boards = |list: &[(i32, i32)]| {
|
||||
let all: String = list.iter().map(|(x, y)| board(*x, *y)).collect();
|
||||
format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{all}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n")
|
||||
};
|
||||
// An operator this doesn't know, so the layers aren't read.
|
||||
let unread = |list: &[(i32, i32)]| native(&(boards(list) + &layer("Art", true, &(square(10, 10) + "\n1 Zz"))));
|
||||
// The PDF part: the pages side by side, 20 points apart, a square on each.
|
||||
let mut pdf = pdf_part(10.0, 10.0);
|
||||
let second = pdf_part(130.0, 10.0).layers[0].children().unwrap()[0].clone();
|
||||
let mut second = (*second).clone();
|
||||
second.id = pdf.alloc_id();
|
||||
let layer_id = pdf.layers[0].id;
|
||||
pdf.insert(Some(layer_id), 1, second).unwrap();
|
||||
let mut page = pdf.artboards[0].clone();
|
||||
(page.id, page.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0));
|
||||
pdf.artboards.push(page);
|
||||
let squares = |d: &Document| d.layers[0].children().unwrap().iter().map(|n| n.visual_bounds().unwrap().origin()).collect::<Vec<_>>();
|
||||
let (d, w) = crate::layered_ai(&private(&unread(&[(0, 0), (0, -150)])), pdf.clone(), vec![], false);
|
||||
assert!(w.iter().any(|w| w.contains("layers weren't read")), "{w:?}");
|
||||
let rects: Vec<_> = d.artboards.iter().map(|a| a.rect).collect();
|
||||
assert_eq!(rects, [vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0), vectorcraft_geom::Rect::new(0.0, 150.0, 100.0, 250.0)]);
|
||||
let moved = squares(&d);
|
||||
assert!((moved[0] - squares(&pdf)[0]).hypot() < 1e-6, "the first page's art stays: {moved:?}");
|
||||
assert!((moved[1] - vectorcraft_geom::Point::new(10.0, 160.0)).hypot() < 1e-6, "the second's goes with its page: {moved:?}");
|
||||
// Artboards that don't match the pages leave them in a row.
|
||||
for list in [&[(0, 0)][..], &[(0, 0), (0, -150), (200, 0)]] {
|
||||
let (d, _) = crate::layered_ai(&private(&unread(list)), pdf.clone(), vec![], false);
|
||||
assert_eq!(d.artboards[1].rect, pdf.artboards[1].rect, "{list:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gray_is_a_grayscale_colour() {
|
||||
// `g` is a gray: 0.25 is 75 % of the black ink, as the page's own CMYK black says; the colour
|
||||
|
||||
@@ -977,7 +977,7 @@ impl Renderer {
|
||||
fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec<NodeId>)> {
|
||||
let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?;
|
||||
let doc = std::ptr::from_ref(f.doc) as usize;
|
||||
if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) {
|
||||
if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) {
|
||||
self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default()));
|
||||
}
|
||||
let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?;
|
||||
|
||||
@@ -51,6 +51,12 @@ fn main() {
|
||||
}
|
||||
let src = format!("pub static CRAFT_FONTS: &[CraftFont] = &[\n{fonts}];\npub static WEB_FONTS: &[WebFont] = &[\n{web}];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
// Unchanged output keeps its mtime. Rewritten just before this crate compiles, it can read as
|
||||
// newer than that compile where cargo's recorded times lose their sub-second part (ecryptfs
|
||||
// home directories), and the next build would compile this crate and everything above it again.
|
||||
if std::fs::read_to_string(&out).is_ok_and(|old| old == src) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
//! Both pick the key objects of a blend, and click or drag ruler guides ([`crate::rulerguide`]).
|
||||
//! Direct Selection also edits a blend's spine: drag its points (a key object on a point moves
|
||||
//! with it) and, once a point is clicked, its handles; and the points of selected gradient meshes
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]). Dragging a corner or an edge of area
|
||||
//! and mesh envelopes and their handles ([`MeshEdit`]; a solid fill colour recolours a gradient
|
||||
//! mesh's point last clicked). Dragging a corner or an edge of area
|
||||
//! type's frame reshapes the type area (`text.reshapeArea`): the text reflows at its size.
|
||||
//! Dragging the brackets of selected type on a path moves or flips it ([`crate::pathtype`]).
|
||||
//!
|
||||
@@ -291,6 +292,9 @@ impl Tool for DirectSelectionTool {
|
||||
fn busy(&self) -> bool {
|
||||
!matches!(self.state, State::Idle) || self.guide.busy()
|
||||
}
|
||||
fn options(&self) -> Value {
|
||||
self.mesh.options()
|
||||
}
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if let Some(out) = self.guide.pointer(cx, ev) {
|
||||
return out;
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's
|
||||
//! new point takes the current fill colour unless Shift); drag a mesh point, or a handle of the
|
||||
//! point last clicked → `object.mesh.movePoint` previews ([`MeshEdit`]); Alt-click a mesh point →
|
||||
//! `object.mesh.deletePoint`.
|
||||
//! `object.mesh.deletePoint`. Clicking a gradient mesh's point selects the mesh, and the next
|
||||
//! solid fill colour recolours just that point.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
@@ -183,7 +184,14 @@ impl Tool for MeshTool {
|
||||
self.edit.unfocus();
|
||||
return vec![Action::Exec("object.mesh.deletePoint".into(), json!({"id": g.id.0, "index": g.index}))];
|
||||
}
|
||||
return self.edit.press(g);
|
||||
// A gradient mesh's point is picked for the next fill colour, so its mesh is
|
||||
// what gets selected.
|
||||
let mut out = vec![];
|
||||
if cx.selection.objects != [g.id] && cx.doc.node(g.id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_))) {
|
||||
out.push(Action::Exec("select.set".into(), json!({"ids": [g.id.0]})));
|
||||
}
|
||||
out.extend(self.edit.press(g));
|
||||
return out;
|
||||
}
|
||||
self.edit.unfocus();
|
||||
let Some((top, leaf)) = hit_top(cx, p) else { return vec![] };
|
||||
@@ -236,6 +244,9 @@ impl Tool for MeshTool {
|
||||
None => Cursor::PenAdd,
|
||||
}
|
||||
}
|
||||
fn options(&self) -> serde_json::Value {
|
||||
self.edit.options()
|
||||
}
|
||||
fn busy(&self) -> bool {
|
||||
self.edit.dragging()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Dragging mesh points and their handles on the canvas, shared by the Mesh tool and Direct
|
||||
//! Selection: gradient meshes and mesh envelopes alike (`object.mesh.movePoint`). Clicking a
|
||||
//! point focuses it: its handles show and can be dragged too.
|
||||
//! point focuses it: its handles show and can be dragged too, and on a gradient mesh a solid fill
|
||||
//! colour recolours it (the tool's `meshPoint` option).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::live::{GradientMesh, envelope_grid};
|
||||
use vectorcraft_doc::{EnvelopeKind, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
@@ -91,6 +92,12 @@ impl MeshEdit {
|
||||
self.focus = None;
|
||||
}
|
||||
|
||||
/// `{meshPoint: {id, index}|null}`: the focused point, which a solid fill colour recolours
|
||||
/// (`paint.setFill` on a gradient mesh).
|
||||
pub fn options(&self) -> Value {
|
||||
json!({"meshPoint": self.focus.map(|(id, index)| json!({"id": id.0, "index": index}))})
|
||||
}
|
||||
|
||||
/// The focused point's handles (lines and ends).
|
||||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
let Some((id, index)) = self.focus else { return vec![] };
|
||||
|
||||
@@ -9,6 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
@@ -33,6 +34,7 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
wat = { workspace = true }
|
||||
|
||||
|
||||
@@ -21,7 +21,13 @@ fn main() {
|
||||
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
||||
.unwrap_or_default();
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
let src = generate(&json, &people);
|
||||
// Unchanged output keeps its mtime, so the next build doesn't compile this crate again
|
||||
// (see crates/text/build.rs).
|
||||
if std::fs::read_to_string(&out).is_ok_and(|old| old == src) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
+179
-12
@@ -47,13 +47,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
// With the macOS menu bar the menus are at the top of the screen instead.
|
||||
let menus_end = if app.services.native_menu.is_some() {
|
||||
let full = ui.max_rect();
|
||||
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text);
|
||||
ui.cursor().min.x
|
||||
} else {
|
||||
menus::menu_bar(app, ui)
|
||||
};
|
||||
let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) };
|
||||
// The right-side group fills the space after the menus from the right; when it runs
|
||||
// short, Discord goes first (it is also under Help), then the search icon (the palette
|
||||
// stays under its shortcut), then the workspace switcher narrows.
|
||||
@@ -97,6 +91,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.add_space(10.0);
|
||||
crate::community::discord_button(app, ui, false);
|
||||
}
|
||||
if app.services.native_menu.is_some() {
|
||||
let title = ui.painter().layout_no_wrap("VectorCraft".into(), egui::FontId::proportional(13.5), t.text);
|
||||
let title_rect = egui::Rect::from_center_size(full.center(), title.size());
|
||||
// Keep the title centered only while the interactive groups leave room for it.
|
||||
if title_rect.left() >= menus_end + 8.0 && title_rect.right() <= ui.min_rect().left() - 8.0 {
|
||||
ui.painter().galley(title_rect.min, title, t.text);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if custom {
|
||||
@@ -153,7 +155,9 @@ pub enum AnchorControls {
|
||||
|
||||
/// Which anchor controls show for the selection and the active tool.
|
||||
pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls {
|
||||
let Some(st) = app.session.active() else { return AnchorControls::None };
|
||||
let Some(st) = app.session.active() else {
|
||||
return AnchorControls::None;
|
||||
};
|
||||
if !st.selection.anchors.is_empty() {
|
||||
return AnchorControls::All;
|
||||
}
|
||||
@@ -229,7 +233,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
|
||||
let anchors = anchor_controls(app);
|
||||
let label = match &first {
|
||||
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => tl!("Anchor Point"),
|
||||
Some(_) if sel.len() == 1 && anchors != AnchorControls::None => {
|
||||
tl!("Anchor Point")
|
||||
}
|
||||
Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => {
|
||||
if im.link.is_some() {
|
||||
tl!("Linked File")
|
||||
@@ -237,7 +243,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
tl!("Embedded")
|
||||
}
|
||||
}
|
||||
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => tl!("Image Tracing"),
|
||||
Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => {
|
||||
tl!("Image Tracing")
|
||||
}
|
||||
_ => tl!(crate::panels::appearance::object_label(app)),
|
||||
};
|
||||
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
|
||||
@@ -571,11 +579,12 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let more = app.background.jobs.len() - 1;
|
||||
let label = crate::i18n::msg(&job.label);
|
||||
let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") };
|
||||
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
|
||||
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
|
||||
ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate());
|
||||
} else if !app.ui.status.is_empty() {
|
||||
// The message stays English in `ui.status` (agents and tests read it).
|
||||
ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text));
|
||||
let message = crate::i18n::msg(&app.ui.status);
|
||||
ui.add(egui::Label::new(egui::RichText::new(message).size(11.0).color(t.text)).truncate());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1074,3 +1083,161 @@ mod tests {
|
||||
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod visual_regressions {
|
||||
use super::*;
|
||||
|
||||
fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput {
|
||||
let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() };
|
||||
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale);
|
||||
input
|
||||
}
|
||||
|
||||
struct NativeMenus;
|
||||
impl crate::native_menu::Backend for NativeMenus {
|
||||
fn sync(&mut self, _: &crate::native_menu::MenuBar) {}
|
||||
fn drain(&mut self) -> Vec<crate::native_menu::Event> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_chrome_visual_fixtures() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else {
|
||||
return;
|
||||
};
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
|
||||
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
|
||||
let mut ready = false;
|
||||
let mut harness =
|
||||
egui_kittest::Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !ready {
|
||||
egui_extras::install_image_loaders(ui.ctx());
|
||||
theme::install_fonts(ui.ctx());
|
||||
theme::apply(ui.ctx(), brightness);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
app_bar(app, ui);
|
||||
status_bar(app, ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.run_steps(4);
|
||||
let stem = format!("vectorcraft-chrome-{brightness:?}-{width}-{scale}x");
|
||||
harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap();
|
||||
let mut regions = serde_json::Map::new();
|
||||
for id in ["app_bar", "status_bar"] {
|
||||
let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect;
|
||||
regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()]));
|
||||
}
|
||||
let dynamic: Vec<_> = harness
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) if text.galley.job.text.contains(" fps") => {
|
||||
let r = text.visual_bounding_rect();
|
||||
Some(json!([r.left(), r.top(), r.width(), r.height()]))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(
|
||||
directory.join(format!("{stem}.json")),
|
||||
serde_json::to_vec_pretty(&json!({
|
||||
"screen": [width, 200.0], "scale": scale, "regions": regions, "native_dynamic_text_regions": dynamic
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_title_does_not_overlap_header_controls() {
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, brightness);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus)));
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| app_bar(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let texts: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let title = texts.iter().find(|text| text.galley.job.text == "VectorCraft");
|
||||
if width >= 1280.0 {
|
||||
assert!(title.is_some(), "the title remains visible when the header has room");
|
||||
}
|
||||
if let Some(title) = title {
|
||||
for text in texts.iter().filter(|text| text.galley.job.text != "VectorCraft") {
|
||||
assert!(
|
||||
!title.visual_bounding_rect().intersects(text.visual_bounding_rect()),
|
||||
"{width}: title overlaps {:?}",
|
||||
text.galley.job.text
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_status_stays_between_navigation_and_performance_readouts() {
|
||||
for brightness in theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, brightness);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10));
|
||||
let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| status_bar(&mut app, ui));
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(ctx.content_rect().size(), vec2(width, 640.0));
|
||||
assert_eq!(ctx.pixels_per_point(), scale);
|
||||
let texts: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
egui::Shape::Text(text) => Some(text),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let status = texts.iter().find(|text| text.galley.job.text.starts_with("Saved ")).expect("status stays visible");
|
||||
assert_eq!(status.galley.rows.len(), 1, "status is one line");
|
||||
assert!(status.galley.elided, "long status retains the full text for the label tooltip");
|
||||
let bounds = status.visual_bounding_rect();
|
||||
assert!(bounds.left() >= 0.0 && bounds.right() <= width, "{width}: {bounds:?}");
|
||||
for text in texts.iter().filter(|text| !text.galley.job.text.starts_with("Saved ")) {
|
||||
assert!(!bounds.intersects(text.visual_bounding_rect()), "{width}: status overlaps {:?}", text.galley.job.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,16 @@ use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: 560.0, ..DialogSpec::FORM };
|
||||
/// A compact drawer two tools wide, however wide the window (#1018); the dialog frame narrows it
|
||||
/// further in a narrow window.
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: WIDTH, max_width: Some(WIDTH), ..DialogSpec::FORM };
|
||||
|
||||
/// A tool's cell: its icon and name.
|
||||
const CELL: egui::Vec2 = vec2(176.0, 28.0);
|
||||
const ICON: f32 = 20.0;
|
||||
/// The drawer's width: two cells, the gap between them and room for the scroll bar.
|
||||
const WIDTH: f32 = 2.0 * CELL.x + 24.0;
|
||||
|
||||
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
|
||||
/// Advanced toolbar shows.
|
||||
@@ -107,4 +112,26 @@ mod tests {
|
||||
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
|
||||
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
|
||||
}
|
||||
|
||||
/// The drawer stays two tools wide in a wide window and fits a narrow one (#1018).
|
||||
#[test]
|
||||
fn all_tools_dialog_has_a_bounded_width() {
|
||||
use egui::{Pos2, RawInput, Rect};
|
||||
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.ui.dialog = Some(Dialog::new("allTools", serde_json::json!({})));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
for width in [1280.0, 430.0] {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0));
|
||||
for _ in 0..6 {
|
||||
let mut out = ctx.run_ui(RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "allTools")))).expect("drawer is shown");
|
||||
assert!(rect.width() <= width, "drawer exceeds viewport: {rect:?}");
|
||||
assert!(rect.width() < 3.0 * CELL.x, "drawer wider than two tools: {rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Corners").int
|
||||
/// `corners` (default: the selected corners), filled in with their kind and radius.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let id = match p.get("id").and_then(Value::as_u64) {
|
||||
Some(id) => NodeId(id),
|
||||
let id = match p.get("id") {
|
||||
Some(v) => NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?),
|
||||
None => match st.selection.objects[..] {
|
||||
[id] => id,
|
||||
_ => return Err("select one path, or give its `id`".into()),
|
||||
@@ -37,11 +37,15 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let Some(live) = st.doc.node(id).and_then(LiveCorners::of) else {
|
||||
return Err("Corners edits the corners of a path".into());
|
||||
};
|
||||
let corners: BTreeSet<usize> = match p.get("corners").and_then(Value::as_array) {
|
||||
Some(a) => a.iter().filter_map(Value::as_u64).filter_map(|k| usize::try_from(k).ok()).filter(|k| live.corner(*k).is_some()).collect(),
|
||||
let corners: BTreeSet<usize> = match p.get("corners") {
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().map(|k| usize::try_from(k.as_u64()?).ok()).collect())
|
||||
.ok_or_else(|| format!("`corners` must be an array of anchor indices, not {v}"))?,
|
||||
None => live.picked(st.selection.partial(id)),
|
||||
};
|
||||
if corners.is_empty() {
|
||||
// Every corner given is one of the path's: none is left out of the edit.
|
||||
if corners.is_empty() || !corners.iter().all(|k| live.corner(*k).is_some()) {
|
||||
return Err("give corners of the path: anchors between two straight sides".into());
|
||||
}
|
||||
let (radius, kind) = live.style(&corners);
|
||||
@@ -149,6 +153,25 @@ mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// Explicit UI-command parameters cannot silently fall back to the selection or drop corners.
|
||||
#[test]
|
||||
fn malformed_explicit_corner_targets_never_open_a_partial_edit() {
|
||||
let (mut app, id) = app();
|
||||
for params in [
|
||||
json!({"id": "invalid"}),
|
||||
json!({"id": -1}),
|
||||
json!({"id": id, "corners": [0, "bad", 2]}),
|
||||
json!({"id": id, "corners": [0, 1.5]}),
|
||||
json!({"id": id, "corners": [0, 9]}),
|
||||
json!({"id": id, "corners": "bad"}),
|
||||
] {
|
||||
assert!(app.run("ui.corners", params.clone()).is_err(), "accepted {params}");
|
||||
assert!(app.ui.dialog.is_none(), "opened a partial edit for {params}");
|
||||
}
|
||||
app.run("ui.corners", json!({"id": id, "corners": [0, 2]})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["corners"], json!([0, 2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_direct_selected_corner_edits_it_alone() {
|
||||
let (mut app, id) = app();
|
||||
|
||||
@@ -353,8 +353,10 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.painter().rect_filled(r, 2.0, egui::Color32::WHITE);
|
||||
}
|
||||
}
|
||||
if let Some(b) = boards.get_mut(i) {
|
||||
ui.checkbox(b, name);
|
||||
if let Some(b) = boards.get_mut(i)
|
||||
&& crate::widgets::check_name(ui, name, *b)
|
||||
{
|
||||
*b ^= true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -399,10 +401,10 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(46.0, 46.0), egui::Sense::hover());
|
||||
crate::panels::asset_export::paint_thumb(ui, st, a, r);
|
||||
let mut checked = on.contains(&a.id);
|
||||
if ui.checkbox(&mut checked, a.name.as_str()).changed() {
|
||||
let checked = on.contains(&a.id);
|
||||
if crate::widgets::check_name(ui, &a.name, checked) {
|
||||
on.retain(|id| *id != a.id);
|
||||
if checked {
|
||||
if !checked {
|
||||
on.push(a.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,8 +320,8 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String,
|
||||
crate::widgets::range_field(ui, ("plugin-int", k), x, *min as f64..=*max as f64, "", 0, 100.0).map(|x| json!(x as i64))
|
||||
}
|
||||
ParamSpec::Bool { .. } => {
|
||||
let mut b = cur.as_bool().unwrap_or(false);
|
||||
ui.checkbox(&mut b, "").changed().then(|| json!(b))
|
||||
let b = cur.as_bool().unwrap_or(false);
|
||||
crate::widgets::check(ui, "", b, true).then(|| json!(!b))
|
||||
}
|
||||
ParamSpec::Choice { options, .. } => {
|
||||
let labels: Vec<&str> = options.iter().map(String::as_str).collect();
|
||||
|
||||
@@ -54,9 +54,16 @@ fn fields_of(n: &Node) -> serde_json::Map<String, Value> {
|
||||
/// the current layer), filled in from the first.
|
||||
pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document open")?;
|
||||
let ids: Vec<NodeId> = match (p.get("ids").and_then(Value::as_array), p.get("id").and_then(Value::as_u64)) {
|
||||
(Some(a), _) => a.iter().filter_map(Value::as_u64).map(NodeId).collect(),
|
||||
(None, Some(id)) => vec![NodeId(id)],
|
||||
// Rows given are used as given: a malformed or unknown id fails rather than being skipped or
|
||||
// the dialog opening on the highlighted rows instead.
|
||||
let row = |v: &Value| -> Result<NodeId, String> {
|
||||
let id = NodeId(v.as_u64().ok_or_else(|| format!("object ids are non-negative integers, not {v}"))?);
|
||||
st.doc.node(id).map(|_| id).ok_or_else(|| "no such layer or object".to_string())
|
||||
};
|
||||
let ids: Vec<NodeId> = match (p.get("ids"), p.get("id")) {
|
||||
(Some(Value::Array(a)), _) => a.iter().map(row).collect::<Result<_, _>>()?,
|
||||
(Some(v), _) => return Err(format!("`ids` must be an array of object ids, not {v}")),
|
||||
(None, Some(v)) => vec![row(v)?],
|
||||
(None, None) => {
|
||||
let rows = st.highlighted_rows();
|
||||
if rows.is_empty() { st.current_layer().into_iter().collect() } else { rows }
|
||||
@@ -252,6 +259,21 @@ mod tests {
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
|
||||
}
|
||||
|
||||
/// Explicit object ids must not silently disappear or fall back to the highlighted row.
|
||||
#[test]
|
||||
fn invalid_layer_options_targets_do_not_open_a_partial_dialog() {
|
||||
let mut app = app();
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
for params in
|
||||
[json!({"ids": [id, "invalid"]}), json!({"ids": [id, 999999]}), json!({"ids": "invalid"}), json!({"id": "invalid"}), json!({"id": null})]
|
||||
{
|
||||
assert!(app.run("ui.layerOptions", params.clone()).is_err(), "accepted {params}");
|
||||
assert!(app.ui.dialog.is_none(), "opened partial options for {params}");
|
||||
}
|
||||
app.run("ui.layerOptions", json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["ids"], json!([id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_options_edit_the_layer_and_ok_is_one_undo_step() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -93,14 +93,15 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
super::form::check(ui, d, "__showAxis", tl!("Show axis"));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
|
||||
let response = ui.allocate_ui_with_layout(
|
||||
let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
|
||||
let tip = tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.");
|
||||
let toggled = ui.allocate_ui_with_layout(
|
||||
vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0))
|
||||
widgets::check_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip)
|
||||
},
|
||||
).inner;
|
||||
if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(visible));
|
||||
if toggled {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(!visible));
|
||||
}
|
||||
if reset_dot(ui, "expandVisibleOnly", !visible, "true") {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(true));
|
||||
@@ -111,9 +112,9 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
|
||||
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut shade = d.bool("shade");
|
||||
if ui.checkbox(&mut shade, tl!("Shading")).changed() {
|
||||
d.fields.insert("shade".into(), json!(shade));
|
||||
let shade = d.bool("shade");
|
||||
if widgets::check(ui, tl!("Shading"), shade, true) {
|
||||
d.fields.insert("shade".into(), json!(!shade));
|
||||
}
|
||||
ui.add_space((ui.available_width() - 24.0).max(0.0));
|
||||
if reset_dot(ui, "shade", !shade, "true") {
|
||||
|
||||
@@ -145,9 +145,9 @@ fn option_row(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, o: &Forma
|
||||
let value = d.fields.get(o.name).cloned().unwrap_or(Value::Null);
|
||||
if o.ty == "boolean" {
|
||||
ui.label("");
|
||||
let mut on = value.as_bool().unwrap_or(false);
|
||||
if ui.checkbox(&mut on, label.trim_end_matches(':')).on_hover_text(o.description).changed() {
|
||||
d.fields.insert(o.name.into(), json!(on));
|
||||
let on = value.as_bool().unwrap_or(false);
|
||||
if widgets::check_tip(ui, label.trim_end_matches(':'), on, true, o.description) {
|
||||
d.fields.insert(o.name.into(), json!(!on));
|
||||
}
|
||||
ui.end_row();
|
||||
if o.name == "separateArtboards" {
|
||||
|
||||
@@ -202,9 +202,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
label(ui, &v.name);
|
||||
let key = value_key(&v.name);
|
||||
match v.kind {
|
||||
VariableKind::Text => text(ui, d, &key, 220.0),
|
||||
VariableKind::Text => {
|
||||
text(ui, d, &key, 220.0);
|
||||
}
|
||||
VariableKind::Visibility => checkbox(ui, d, &key),
|
||||
};
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
@@ -224,13 +226,11 @@ fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Respo
|
||||
}
|
||||
|
||||
/// A checkbox for `key`, written back as it is toggled.
|
||||
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) -> egui::Response {
|
||||
let mut b = d.bool(key);
|
||||
let r = ui.checkbox(&mut b, "");
|
||||
if r.changed() {
|
||||
d.fields.insert(key.into(), json!(b));
|
||||
fn checkbox(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
|
||||
let b = d.bool(key);
|
||||
if widgets::check(ui, "", b, true) {
|
||||
d.fields.insert(key.into(), json!(!b));
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// A dropdown over the document's names `options` (shown as they are, never translated) for
|
||||
|
||||
+208
-28
@@ -26,7 +26,7 @@ fn column_left_id() -> egui::Id {
|
||||
|
||||
/// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's
|
||||
/// (egui temp memory, one frame old). Popped-out panels open left of it.
|
||||
fn dock_left_id() -> egui::Id {
|
||||
pub(crate) fn dock_left_id() -> egui::Id {
|
||||
egui::Id::new("dock-left")
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
|
||||
/// The strip along the top of a dock column with its double arrow: » collapses the dock to icons,
|
||||
/// « expands it. The whole strip is the click target (as on the toolbar). True when clicked.
|
||||
fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
|
||||
pub(crate) fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());
|
||||
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
|
||||
@@ -66,20 +66,22 @@ fn toggle(app: &mut VectorcraftApp, collapsed: bool) {
|
||||
|
||||
/// One icon of the column: a click pops its panel out (or puts it away); dragged out of the
|
||||
/// column (`column`), the panel floats.
|
||||
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, column: Rect) {
|
||||
fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, _column: Rect) {
|
||||
let open = app.ui.open_panel.as_deref() == Some(id);
|
||||
// Its own id: the icons below it move up when it floats, and mustn't take over its drag.
|
||||
let (_, rect) = ui.allocate_space(vec2(30.0, 30.0));
|
||||
let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label)));
|
||||
if resp.clicked() {
|
||||
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
|
||||
}
|
||||
if resp.dragged() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !column.contains(p)) {
|
||||
floating::tear(app, ui.ctx(), &[id], id, floating::strip_grab(ui.ctx(), resp.rect.left()));
|
||||
}
|
||||
crate::panel_docking::legacy_tab(app, ui, id, &resp);
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if crate::panel_docking::show(app, ui) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let shown = floating::shown_tab(&app.ui);
|
||||
// Main tabbed group, unless it is collapsed to icons or all its panels float.
|
||||
@@ -173,36 +175,36 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let out = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
|
||||
let outside = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p));
|
||||
// What a drag out of the strip floats: the panels, the one shown, where its tab began.
|
||||
let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None;
|
||||
let mut x = strip.left();
|
||||
for tab in DockTab::ALL {
|
||||
let (id, label, _) = tab.info();
|
||||
if floating::group_of(&app.ui, id).is_some() {
|
||||
continue;
|
||||
}
|
||||
let galley =
|
||||
ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if tab == active { t.text_strong } else { t.text_dim });
|
||||
let r = Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
|
||||
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click_and_drag());
|
||||
if tab == active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
}
|
||||
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let tabs: Vec<_> = floating::docked_tabs(&app.ui).collect();
|
||||
let names: Vec<_> = tabs
|
||||
.iter()
|
||||
.map(|tab| {
|
||||
let (id, label, _) = tab.info();
|
||||
(id, label)
|
||||
})
|
||||
.collect();
|
||||
let selected = tabs.iter().position(|&tab| tab == active).unwrap_or(0);
|
||||
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
|
||||
let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0));
|
||||
let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true);
|
||||
for (i, resp) in out.responses {
|
||||
let Some(&tab) = tabs.get(i) else { continue };
|
||||
let id = tab.info().0;
|
||||
if resp.clicked() {
|
||||
app.ui.dock_tab = tab;
|
||||
} else if resp.dragged() && out {
|
||||
tear = Some((vec![id], id, r.left()));
|
||||
}
|
||||
x = r.right();
|
||||
crate::panel_docking::legacy_tab(app, ui, id, &resp);
|
||||
}
|
||||
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
|
||||
let rest = Rect::from_min_max(egui::pos2(x, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
|
||||
if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() {
|
||||
app.ui.dock_tab = tab;
|
||||
}
|
||||
let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom()));
|
||||
if rest.width() > 0.0 {
|
||||
let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag());
|
||||
if resp.dragged() && out {
|
||||
if resp.dragged() && outside {
|
||||
tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left()));
|
||||
}
|
||||
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
|
||||
@@ -215,6 +217,92 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool {
|
||||
floating::group_of(&app.ui, active.info().0).is_some()
|
||||
}
|
||||
|
||||
/// App styling and overflow menus around the shared tab fitting/interaction components. Docking
|
||||
/// and floating groups consume the responses themselves, preserving their different drag rules.
|
||||
pub(crate) struct PanelTabs {
|
||||
pub responses: Vec<(usize, egui::Response)>,
|
||||
pub picked: Option<usize>,
|
||||
pub right: f32,
|
||||
}
|
||||
|
||||
pub(crate) fn panel_tabs(
|
||||
ui: &mut Ui,
|
||||
id: &str,
|
||||
area: Rect,
|
||||
tabs: &[(&str, &str)],
|
||||
selected: usize,
|
||||
font: egui::FontId,
|
||||
separators: bool,
|
||||
) -> PanelTabs {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let labels: Vec<_> = tabs.iter().map(|&(_, label)| tl!(label)).collect();
|
||||
let galleys: Vec<_> = labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, label)| ui.painter().layout_no_wrap((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim }))
|
||||
.collect();
|
||||
let natural: Vec<_> = galleys.iter().map(|g| g.size().x + 24.0).collect();
|
||||
let fit = craft_ui::tabs::fit(&natural, selected, area.width(), 40.0, 18.0);
|
||||
let mut out = PanelTabs { responses: Vec::with_capacity(fit.shown.len()), picked: None, right: area.left() };
|
||||
for (i, width) in fit.shown {
|
||||
let Some((key, _)) = tabs.get(i) else { continue };
|
||||
let Some(label) = labels.get(i) else { continue };
|
||||
let Some(galley) = galleys.get(i) else { continue };
|
||||
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(width, area.height()));
|
||||
let shrunk = natural.get(i).is_some_and(|natural| *natural > width + 0.5);
|
||||
let galley = if shrunk {
|
||||
let mut job =
|
||||
egui::text::LayoutJob::simple_singleline((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim });
|
||||
job.wrap =
|
||||
egui::text::TextWrapping { max_width: (width - 8.0).max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') };
|
||||
ui.painter().layout_job(job)
|
||||
} else {
|
||||
galley.clone()
|
||||
};
|
||||
let resp = craft_ui::tabs::Tab::new(ui.id().with((id, key)), label, i == selected)
|
||||
.sense(Sense::click_and_drag())
|
||||
.focus_stroke(Stroke::new(1.0, t.accent))
|
||||
.show_at(ui, rect, |ui, _| {
|
||||
if i == selected {
|
||||
ui.painter().rect_filled(rect, 0.0, t.panel);
|
||||
}
|
||||
ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).galley(rect.center() - galley.size() / 2.0, galley, t.text);
|
||||
if separators {
|
||||
ui.painter().line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0, t.border));
|
||||
}
|
||||
});
|
||||
out.responses.push((i, if shrunk { resp.on_hover_text(*label) } else { resp }));
|
||||
out.right = rect.right();
|
||||
}
|
||||
if !fit.overflow.is_empty() && fit.overflow_width > 0.0 {
|
||||
let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(fit.overflow_width, area.height()));
|
||||
let resp = ui.interact(rect, ui.id().with((id, "overflow")), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 0.0, t.hover);
|
||||
}
|
||||
if resp.has_focus() && rect.width() > 4.0 {
|
||||
ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
icons::paint(ui, "chevrons-right", Rect::from_center_size(rect.center(), vec2(12.0, 12.0)).intersect(rect), t.text);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panels")));
|
||||
let resp = resp.on_hover_text(tl!("Panels"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
widgets::menu_scroll(ui, |ui| {
|
||||
for i in fit.overflow {
|
||||
if let Some(label) = labels.get(i)
|
||||
&& ui.button(*label).clicked()
|
||||
{
|
||||
out.picked = Some(i);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
out.right = rect.right();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels
|
||||
/// while the dock is collapsed. Dragging its title floats it.
|
||||
pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
@@ -337,6 +425,98 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::toolbar::tests::{wheel, widget_rects};
|
||||
|
||||
#[test]
|
||||
fn tab_headers_fit_narrow_widths_keep_the_selected_tab_and_clip_labels() {
|
||||
let ctx = egui::Context::default();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, theme::Brightness::MediumDark);
|
||||
let names = [("one", "Long panel name"), ("two", "Another long panel name"), ("three", "Layers")];
|
||||
for width in [0.0, 1.0, 5.0, 18.0, 19.0, 80.0, 180.0, 300.0] {
|
||||
for selected in 0..names.len() {
|
||||
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(width, 32.0));
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let out = panel_tabs(ui, "fit-test", area, &names, selected, theme::semibold(12.5), true);
|
||||
assert!(out.responses.iter().any(|(i, _)| *i == selected), "selected index {selected} stays on the strip");
|
||||
assert!(out.right <= area.right() + 0.001, "{width}: strip ends at {}", out.right);
|
||||
for (_, response) in &out.responses {
|
||||
if response.rect.is_positive() {
|
||||
assert!(area.contains_rect(response.rect), "{width}: {:?}", response.rect);
|
||||
}
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
for shape in output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))) {
|
||||
assert!(shape.clip_rect.right() <= area.right() + 0.001, "{width}: a label is clipped to its tab");
|
||||
}
|
||||
if width > 0.0 && width < 18.0 {
|
||||
assert!(
|
||||
output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(rect) if rect.brush.is_some())),
|
||||
"the actual overflow SVG was painted"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_menu_can_activate_a_hidden_panel() {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
egui_extras::install_image_loaders(&ctx);
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, theme::Brightness::MediumDark);
|
||||
let names = [("properties", "Properties"), ("layers", "Layers"), ("libraries", "Libraries")];
|
||||
let mut selected = 0;
|
||||
let mut time = 0.0;
|
||||
let mut frame = |events| {
|
||||
time += 0.1;
|
||||
let input = egui::RawInput {
|
||||
events,
|
||||
time: Some(time),
|
||||
screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 300.0))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = ctx.run_ui(input, |ui| {
|
||||
let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(90.0, 32.0));
|
||||
let out = panel_tabs(ui, "overflow-test", area, &names, selected, theme::semibold(12.5), true);
|
||||
if let Some(picked) = out.picked {
|
||||
selected = picked;
|
||||
}
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
output
|
||||
};
|
||||
let button = |output: &egui::FullOutput, label: &str| {
|
||||
let bounds = output
|
||||
.platform_output
|
||||
.accesskit_update
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button)
|
||||
.unwrap()
|
||||
.1
|
||||
.bounds()
|
||||
.unwrap();
|
||||
egui::pos2((bounds.x0 + bounds.x1) as f32 * 0.5, (bounds.y0 + bounds.y1) as f32 * 0.5)
|
||||
};
|
||||
let press =
|
||||
|pos, pressed| egui::Event::PointerButton { pos, pressed, button: egui::PointerButton::Primary, modifiers: egui::Modifiers::NONE };
|
||||
frame(vec![]);
|
||||
let output = frame(vec![]);
|
||||
let overflow = button(&output, "Panels");
|
||||
frame(vec![egui::Event::PointerMoved(overflow), press(overflow, true)]);
|
||||
frame(vec![press(overflow, false)]);
|
||||
let output = frame(vec![]);
|
||||
let libraries = button(&output, "Libraries");
|
||||
frame(vec![egui::Event::PointerMoved(libraries), press(libraries, true)]);
|
||||
frame(vec![press(libraries, false)]);
|
||||
assert_eq!(selected, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_flyout_stays_left_of_the_icon_column() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
|
||||
+283
-71
@@ -50,7 +50,7 @@ const TRAVEL: f32 = 4.0;
|
||||
|
||||
/// What a move carries.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Moving {
|
||||
pub(crate) enum Moving {
|
||||
/// The floating group holding this panel.
|
||||
Panel(&'static str),
|
||||
/// The floating Tools panel.
|
||||
@@ -59,16 +59,17 @@ enum Moving {
|
||||
|
||||
/// A move in progress: what moves, where the pointer holds it from its top-left corner, and where
|
||||
/// the press began.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
struct Move {
|
||||
what: Moving,
|
||||
grab: Vec2,
|
||||
from: Pos2,
|
||||
original: UiState,
|
||||
}
|
||||
|
||||
/// Where a moved group or Tools panel lands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Drop {
|
||||
pub(crate) enum Drop {
|
||||
/// Back in the dock (the Tools panel: at the window's left edge).
|
||||
Dock,
|
||||
/// Stacked with the floating group holding this panel.
|
||||
@@ -85,6 +86,15 @@ fn move_id() -> Id {
|
||||
Id::new("floating-move")
|
||||
}
|
||||
|
||||
fn suppressed_id() -> Id {
|
||||
move_id().with("suppressed-until-primary-press")
|
||||
}
|
||||
|
||||
fn gesture_allowed(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape))
|
||||
&& !ctx.data(|data| data.get_temp::<bool>(suppressed_id()).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// The dock (tabbed group and icon column) as last drawn: a group dropped there docks.
|
||||
pub(crate) fn dock_rect_id() -> Id {
|
||||
Id::new("floating-dock-rect")
|
||||
@@ -266,9 +276,12 @@ pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 {
|
||||
}
|
||||
|
||||
/// Start moving `what` with the pointer, held `grab` from its top-left corner.
|
||||
fn start(ctx: &egui::Context, what: Moving, grab: Vec2) {
|
||||
fn start(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, grab: Vec2) {
|
||||
if !gesture_allowed(ctx) {
|
||||
return;
|
||||
}
|
||||
let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return };
|
||||
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from }));
|
||||
ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from, original: app.ui.clone() }));
|
||||
}
|
||||
|
||||
/// Where the pointer holds a group torn off by a press `left` points right of where the group's
|
||||
@@ -281,30 +294,49 @@ pub(crate) fn strip_grab(ctx: &egui::Context, left: f32) -> Vec2 {
|
||||
/// Float `ids` under the pointer, held `grab` from the new group's top-left corner, and carry on
|
||||
/// moving them with the pointer (a tab or group dragged out of the dock or out of its group).
|
||||
pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'static str], active: &'static str, grab: Vec2) {
|
||||
// A restored popout may retain its drag response in the cancellation frame. Do not
|
||||
// immediately tear it out again after track restored the gesture's original layout.
|
||||
if !gesture_allowed(ctx) {
|
||||
return;
|
||||
}
|
||||
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||||
float(&mut app.ui, ids, active, at - grab);
|
||||
start(ctx, Moving::Panel(active), grab);
|
||||
let original = app.ui.clone();
|
||||
let pos = at - grab;
|
||||
if let Err(error) = app.run("window.panel.float", json!({"panel":active,"group":ids.len() > 1,"x":pos.x,"y":pos.y})) {
|
||||
app.ui.status = error;
|
||||
return;
|
||||
}
|
||||
start(app, ctx, Moving::Panel(active), grab);
|
||||
ctx.data_mut(|data| {
|
||||
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
|
||||
drag.original = original;
|
||||
data.insert_temp(move_id(), drag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging
|
||||
/// it out of the panel floats the panel under the pointer; floating at `floating`, a drag moves it.
|
||||
pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &egui::Response, bounds: Rect, floating: Option<Pos2>) {
|
||||
if !gesture_allowed(ctx) {
|
||||
return;
|
||||
}
|
||||
let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return };
|
||||
match floating {
|
||||
Some(pos) if title.drag_started() => start(ctx, Moving::Tools, at - pos),
|
||||
Some(pos) if title.drag_started() => start(app, ctx, Moving::Tools, at - pos),
|
||||
None if title.dragged() && !bounds.contains(at) => {
|
||||
// Held where the press grabbed the title bar.
|
||||
let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(10.0, 7.0), |p| p - bounds.min);
|
||||
let p = at - grab;
|
||||
start(app, ctx, Moving::Tools, grab);
|
||||
app.ui.toolbar_pos = Some([p.x, p.y]);
|
||||
start(ctx, Moving::Tools, grab);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the pointer at `at` would drop `what`, and the zone that lights up for it.
|
||||
fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
|
||||
pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> {
|
||||
let zone = |id: Id| ctx.data(|d| d.get_temp::<Rect>(id)).filter(|r| r.contains(at));
|
||||
let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) };
|
||||
let own = group_of(&app.ui, id);
|
||||
@@ -327,7 +359,39 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
|
||||
let columns = ctx.data(|d| d.get_temp::<Vec<Rect>>(columns_id())).unwrap_or_default();
|
||||
for (i, g) in app.ui.floating_panels.iter().enumerate() {
|
||||
let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue };
|
||||
let Some(mut r) = ctx.data(|d| d.get_temp::<Rect>(group_rect_id(first))) else { continue };
|
||||
let Some(mut r) = ctx
|
||||
.data(|d| d.get_temp::<Rect>(group_rect_id(first)))
|
||||
.or_else(|| ctx.memory(|m| m.area_rect(Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())))))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// A visible window owns hits inside its body. Old rectangles and lower windows
|
||||
// must not capture a drop through another panel or popup; edge insertion remains
|
||||
// available just outside the window. Dock columns reserve their own panel space.
|
||||
if g.docked.is_none() && r.contains(at) {
|
||||
let area = if app.ui.docking.is_some() && !crate::panel_docking::decorated(g) {
|
||||
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
|
||||
} else {
|
||||
let lead = set_of(&app.ui, i).first().and_then(|&index| app.ui.floating_panels.get(index)).and_then(|group| group.panels.first());
|
||||
area_id(lead.map_or(first, String::as_str))
|
||||
};
|
||||
let moving_areas: Vec<Id> = moving
|
||||
.iter()
|
||||
.filter_map(|&index| app.ui.floating_panels.get(index).and_then(|group| group.panels.first()))
|
||||
.flat_map(|panel| [area_id(panel), Id::new("vectorcraft-panel-docking").with(("floating", panel))])
|
||||
.collect();
|
||||
let owner = ctx.memory(|memory| {
|
||||
let layers: Vec<_> = memory.layer_ids().collect();
|
||||
layers.into_iter().rev().find(|layer| {
|
||||
!moving_areas.contains(&layer.id)
|
||||
&& memory.areas().is_visible(layer)
|
||||
&& memory.area_rect(layer.id).is_some_and(|rect| rect.contains(at))
|
||||
})
|
||||
});
|
||||
if owner.is_some_and(|layer| layer.id != area) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// In a dock column that scrolls, only the part in view: a group scrolled away takes nothing.
|
||||
if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) {
|
||||
r = r.intersect(*col);
|
||||
@@ -368,7 +432,7 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2
|
||||
}
|
||||
|
||||
/// Light up a drop zone: over the docked panels, under the floating ones (the moved one too).
|
||||
fn highlight(ctx: &egui::Context, zone: Rect) {
|
||||
pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) {
|
||||
let t = Tokens::get(ctx);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone")));
|
||||
painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
@@ -388,10 +452,43 @@ fn insertion_line(ctx: &egui::Context, line: Rect) {
|
||||
painter.rect_filled(line, CornerRadius::same(2), t.accent);
|
||||
}
|
||||
|
||||
pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) {
|
||||
match target {
|
||||
Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect),
|
||||
Drop::Stack(_) => tab_zone(ctx, rect),
|
||||
Drop::Dock => highlight(ctx, rect),
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel
|
||||
/// follows the pointer and the zone it would drop on lights up; the release drops it there.
|
||||
pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if ctx.input(|input| input.pointer.primary_pressed()) {
|
||||
ctx.data_mut(|data| data.remove::<bool>(suppressed_id()));
|
||||
}
|
||||
let before = app.ui.clone();
|
||||
let Some(m) = ctx.data(|d| d.get_temp::<Move>(move_id())) else { return };
|
||||
if m.original.docking_generation != app.ui.docking_generation {
|
||||
ctx.data_mut(|data| {
|
||||
data.remove::<Move>(move_id());
|
||||
data.insert_temp(suppressed_id(), true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.docking = m.original.docking;
|
||||
app.ui.docking_hidden = m.original.docking_hidden;
|
||||
app.ui.docking_icons = m.original.docking_icons;
|
||||
app.ui.floating_panels = m.original.floating_panels;
|
||||
app.ui.open_panel = m.original.open_panel;
|
||||
app.ui.dock_tab = m.original.dock_tab;
|
||||
app.ui.toolbar_pos = m.original.toolbar_pos;
|
||||
ctx.data_mut(|data| {
|
||||
data.remove::<Move>(move_id());
|
||||
data.insert_temp(suppressed_id(), true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
let at = ctx.input(|i| i.pointer.interact_pos());
|
||||
let target = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_target(app, ctx, m.what, at));
|
||||
if ctx.input(|i| i.pointer.primary_down()) {
|
||||
@@ -409,6 +506,9 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
Moving::Tools => {}
|
||||
}
|
||||
}
|
||||
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
match target {
|
||||
Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line),
|
||||
Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs),
|
||||
@@ -441,6 +541,9 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
Drop::Above(onto) => attach(&mut app.ui, &set, onto, false),
|
||||
Drop::Column(slot) => dock_column(&mut app.ui, &set, slot),
|
||||
}
|
||||
if let Err(error) = crate::panel_docking::reconcile_native(app, before) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
}
|
||||
|
||||
/// What a press on a floating or docked group did.
|
||||
@@ -490,11 +593,12 @@ fn docked(ui: &UiState, set: &[usize]) -> bool {
|
||||
/// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`].
|
||||
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 {
|
||||
crate::panel_docking::finish_native_drop(app, ctx);
|
||||
return;
|
||||
}
|
||||
let mut action = None;
|
||||
for gi in 0..app.ui.floating_panels.len() {
|
||||
if leads(&app.ui, gi) {
|
||||
if leads(&app.ui, gi) && (app.ui.docking.is_none() || app.ui.floating_panels.get(gi).is_some_and(crate::panel_docking::decorated)) {
|
||||
let set = set_of(&app.ui, gi);
|
||||
if !docked(&app.ui, &set) {
|
||||
show_set(app, ctx, &set, &mut action);
|
||||
@@ -502,10 +606,18 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
apply(app, ctx, action);
|
||||
crate::panel_docking::finish_native_drop(app, ctx);
|
||||
}
|
||||
|
||||
/// Carry out what a press on a floating or docked group did.
|
||||
fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>) {
|
||||
if action.is_none() {
|
||||
return;
|
||||
}
|
||||
if matches!(action, Some(Action::Move(..) | Action::Detach(..) | Action::Tear(..) | Action::Undock(..))) && !gesture_allowed(ctx) {
|
||||
return;
|
||||
}
|
||||
let before = app.ui.clone();
|
||||
match action {
|
||||
Some(Action::Activate(gi, k)) => {
|
||||
if let Some(g) = app.ui.floating_panels.get_mut(gi)
|
||||
@@ -531,7 +643,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
|
||||
set_pos(&mut app.ui, gi, [pos.x, pos.y]);
|
||||
}
|
||||
if let Some(at) = ctx.input(|i| i.pointer.interact_pos()) {
|
||||
start(ctx, Moving::Panel(id), at - pos);
|
||||
start(app, ctx, Moving::Panel(id), at - pos);
|
||||
}
|
||||
}
|
||||
Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left),
|
||||
@@ -547,10 +659,19 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option<Action>)
|
||||
}
|
||||
}
|
||||
FloatingPanels::tidy(&mut app.ui.floating_panels);
|
||||
start(ctx, Moving::Panel(id), grab);
|
||||
start(app, ctx, Moving::Panel(id), grab);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
ctx.data_mut(|data| {
|
||||
if let Some(mut drag) = data.get_temp::<Move>(move_id()) {
|
||||
drag.original = before;
|
||||
data.insert_temp(move_id(), drag);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having
|
||||
@@ -566,7 +687,7 @@ fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) {
|
||||
g.pos = [p.x, p.y];
|
||||
}
|
||||
FloatingPanels::tidy(&mut app.ui.floating_panels);
|
||||
start(ctx, Moving::Panel(id), grab);
|
||||
start(app, ctx, Moving::Panel(id), grab);
|
||||
}
|
||||
|
||||
/// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting
|
||||
@@ -655,7 +776,13 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec
|
||||
});
|
||||
rects.push(panel.response.rect);
|
||||
}
|
||||
ui.ctx().data_mut(|d| d.insert_temp(columns_id(), rects.clone()));
|
||||
ui.ctx().data_mut(|d| {
|
||||
let icons = d.get_temp::<Rect>(icons_rect_id());
|
||||
if let Some(left) = icons.map(|r| rects.iter().map(|r| r.left()).fold(r.left(), f32::min)) {
|
||||
d.insert_temp(dock::dock_left_id(), left);
|
||||
}
|
||||
d.insert_temp(columns_id(), rects.clone());
|
||||
});
|
||||
let ctx = ui.ctx().clone();
|
||||
apply(app, &ctx, action);
|
||||
rects
|
||||
@@ -753,18 +880,13 @@ fn draw_groups(
|
||||
let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover());
|
||||
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
|
||||
let lone = g.ids.len() == 1;
|
||||
let mut left = strip.left();
|
||||
for (j, &id) in g.ids.iter().enumerate() {
|
||||
let label = panel(id).map_or(id, |(_, label)| label);
|
||||
let color = if id == g.active { t.text_strong } else { t.text_dim };
|
||||
let galley = ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color);
|
||||
let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP));
|
||||
left = r.right();
|
||||
let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag());
|
||||
if id == g.active && !g.collapsed {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
}
|
||||
ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
|
||||
let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom()));
|
||||
let names: Vec<_> = g.ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect();
|
||||
let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0);
|
||||
let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false);
|
||||
for (j, resp) in out.responses {
|
||||
let Some(&id) = g.ids.get(j) else { continue };
|
||||
if resp.double_clicked() {
|
||||
*action = Some(Action::Collapse(g.gi));
|
||||
} else if resp.clicked() {
|
||||
@@ -775,11 +897,13 @@ fn draw_groups(
|
||||
None => Action::Detach(g.gi, strip.left()),
|
||||
});
|
||||
} else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) {
|
||||
*action = Some(Action::Tear(id, r.left()));
|
||||
*action = Some(Action::Tear(id, resp.rect.left()));
|
||||
}
|
||||
}
|
||||
let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0));
|
||||
let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
|
||||
if let Some(j) = out.picked {
|
||||
*action = Some(Action::Activate(g.gi, j));
|
||||
}
|
||||
let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom()));
|
||||
if rest.width() > 0.0 {
|
||||
let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag());
|
||||
if resp.drag_started() {
|
||||
@@ -1003,7 +1127,9 @@ mod tests {
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
self.time += 0.05;
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, self.screen);
|
||||
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() };
|
||||
let focused =
|
||||
events.iter().rev().find_map(|event| if let Event::WindowFocused(focused) = event { Some(*focused) } else { None }).unwrap_or(true);
|
||||
let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), focused, events, ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
self.ctx
|
||||
.run_ui(raw, |ui| {
|
||||
@@ -1050,11 +1176,22 @@ mod tests {
|
||||
|
||||
/// The floating group whose first panel is `first`, as drawn.
|
||||
fn group(&self, first: &str) -> Rect {
|
||||
self.ctx.memory(|m| m.area_rect(area_id(first))).unwrap()
|
||||
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
|
||||
let id = if self.app.ui.docking.is_some() && !native {
|
||||
Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))
|
||||
} else {
|
||||
area_id(first)
|
||||
};
|
||||
self.ctx.memory(|m| m.area_rect(id)).unwrap()
|
||||
}
|
||||
|
||||
/// A point on a floating group's title bar (left of its ×).
|
||||
fn title(&self, first: &str) -> Pos2 {
|
||||
let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated);
|
||||
if self.app.ui.docking.is_some() && !native {
|
||||
let rect = self.group(first);
|
||||
return rect.left_top() + vec2(6.0, 16.0);
|
||||
}
|
||||
let r = self.group(first);
|
||||
pos2(r.left() + 30.0, r.top() + 1.0 + TITLE / 2.0)
|
||||
}
|
||||
@@ -1068,15 +1205,29 @@ mod tests {
|
||||
r
|
||||
}
|
||||
|
||||
fn shared_tab(&self, panel: &str) -> Rect {
|
||||
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect
|
||||
}
|
||||
|
||||
/// The dock's tabs, left to right.
|
||||
fn dock_tabs(&self) -> Vec<Rect> {
|
||||
if let Some(layout) = &self.app.ui.docking {
|
||||
let root = craft_ui::docking::Layout { root: layout.root.clone(), floating: Vec::new() };
|
||||
return root
|
||||
.panels()
|
||||
.into_iter()
|
||||
.filter_map(|panel| {
|
||||
self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let dock = self.temp_rect(dock_rect_id());
|
||||
self.widgets(|r| r.height() == 32.0 && dock.contains_rect(r))
|
||||
}
|
||||
|
||||
/// The icon column's panel icons, top to bottom.
|
||||
fn icons(&self) -> Vec<Rect> {
|
||||
let dock = self.temp_rect(dock_rect_id());
|
||||
let dock = self.temp_rect(if self.app.ui.docking.is_some() { icons_rect_id() } else { dock_rect_id() });
|
||||
self.widgets(|r| r.size() == vec2(30.0, 30.0) && dock.contains_rect(r))
|
||||
}
|
||||
|
||||
@@ -1089,30 +1240,19 @@ mod tests {
|
||||
fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() {
|
||||
let mut h = Harness::new();
|
||||
let tabs = h.dock_tabs();
|
||||
assert_eq!(tabs.len(), 3, "Properties, Layers, Libraries: {tabs:?}");
|
||||
assert_eq!(tabs.len(), 3);
|
||||
let to = pos2(600.0, 400.0);
|
||||
h.drag(tabs[1].center(), to);
|
||||
assert_eq!(h.groups(), [["layers"]], "Layers floats");
|
||||
assert!(h.group("layers").contains(to), "under the pointer: {:?}", h.group("layers"));
|
||||
assert_eq!(h.dock_tabs().len(), 2, "its tab left the dock");
|
||||
assert_eq!(h.groups(), [["layers"]]);
|
||||
assert!(h.group("layers").contains(to));
|
||||
assert_eq!(h.dock_tabs().len(), 2);
|
||||
assert_eq!(h.app.ui.dock_tab, DockTab::Properties);
|
||||
// A click on its title bar moves nothing, even over the dock.
|
||||
let dock = h.temp_rect(dock_rect_id());
|
||||
h.app.run("window.panel.float", json!({"panel": "layers", "x": dock.left() + 10.0, "y": 300})).unwrap();
|
||||
h.settle();
|
||||
h.click(h.title("layers"));
|
||||
assert_eq!(h.groups(), [["layers"]], "a click doesn't dock it");
|
||||
// Dragged by its title bar onto the dock, the dock lights up, and the release docks it.
|
||||
h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 300})).unwrap();
|
||||
h.settle();
|
||||
let over = dock.center();
|
||||
h.hold(h.title("layers"), over);
|
||||
assert!(h.group("layers").contains(over), "the group follows the pointer");
|
||||
let target = drop_target(&h.app, &h.ctx, Moving::Panel("layers"), over);
|
||||
assert_eq!(target, Some((Drop::Dock, dock)), "the dock is the drop zone, lit whole: its tab comes back");
|
||||
h.release(over);
|
||||
assert!(h.groups().is_empty(), "docked: {:?}", h.groups());
|
||||
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers), "back in the dock, shown");
|
||||
assert_eq!(h.groups(), [["layers"]], "a click only activates the floating tab");
|
||||
let over = h.shared_tab("properties").center();
|
||||
h.drag(h.title("layers"), over);
|
||||
assert!(h.groups().is_empty());
|
||||
assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1127,7 +1267,9 @@ mod tests {
|
||||
assert!(column.width() < 40.0, "only the icon column is left: {column:?}");
|
||||
// Its × puts them back.
|
||||
let g = h.group("properties");
|
||||
h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0));
|
||||
let close =
|
||||
if h.app.ui.docking.is_some() { pos2(g.right() - 36.0, g.top() + 16.0) } else { pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0) };
|
||||
h.click(close);
|
||||
assert!(h.groups().is_empty());
|
||||
assert_eq!(h.dock_tabs().len(), 3);
|
||||
}
|
||||
@@ -1143,32 +1285,40 @@ mod tests {
|
||||
assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::<usize>(), "every group's icons are drawn");
|
||||
h.drag(icons[2].center(), pos2(500.0, 250.0));
|
||||
assert_eq!(h.groups(), [["swatches"]]);
|
||||
assert_eq!(h.icons().len(), count - 1, "its icon left the column");
|
||||
h.drag(h.icons()[0].center(), pos2(500.0, 650.0));
|
||||
assert_eq!(h.icons().len(), count - 1);
|
||||
let separate = pos2(900.0, 650.0);
|
||||
assert!(!h.group("swatches").contains(separate));
|
||||
h.drag(h.icons()[0].center(), separate);
|
||||
assert_eq!(h.groups(), [["swatches"], ["color"]]);
|
||||
// Color's title bar dropped on the Swatches group's tabs stacks them.
|
||||
let onto = h.group("swatches").left_top() + vec2(150.0, 1.0 + TITLE + STRIP / 2.0);
|
||||
h.hold(h.title("color"), onto);
|
||||
assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), onto).map(|t| t.0), Some(Drop::Stack("swatches")));
|
||||
h.release(onto);
|
||||
let onto = h.shared_tab("swatches").right_center() - vec2(3.0, 0.0);
|
||||
h.drag(h.title("color"), onto);
|
||||
assert_eq!(h.groups(), [["swatches", "color"]]);
|
||||
assert_eq!(h.app.ui.floating_panels[0].active, 1, "showing the panel dropped on it");
|
||||
// A click shows a tab; a tab dragged out of the strip floats on its own again.
|
||||
let g = h.group("swatches");
|
||||
let tabs = h.widgets(|r| r.height() == STRIP && g.contains_rect(r));
|
||||
assert_eq!(tabs.len(), 3, "two tabs and the strip right of them: {tabs:?}");
|
||||
h.click(tabs[0].center());
|
||||
assert_eq!(h.app.ui.floating_panels[0].active, 1);
|
||||
h.click(h.shared_tab("swatches").center());
|
||||
assert_eq!(h.app.ui.floating_panels[0].active, 0);
|
||||
let to = pos2(900.0, 500.0);
|
||||
h.drag(tabs[1].center(), to);
|
||||
h.drag(h.shared_tab("color").center(), to);
|
||||
assert_eq!(h.groups(), [["swatches"], ["color"]]);
|
||||
assert!(h.group("color").contains(to));
|
||||
// Window › Color shows it where it floats; Dock Panel puts it back.
|
||||
assert_eq!(h.app.run("window.panel", json!({"panel": "Color"})).unwrap(), json!({"floating": ["color"]}));
|
||||
assert_eq!(crate::menus::checked(&h.app, "window.panel", &json!({"panel": "color"})), Some(true));
|
||||
h.app.run("window.panel.dock", json!({"panel": "color"})).unwrap();
|
||||
h.settle();
|
||||
assert!(h.groups().iter().all(|group| !group.contains(&"color")));
|
||||
assert_eq!(h.icons().len(), count - 1, "Color is back in the column");
|
||||
assert_eq!(h.app.ui.open_panel.as_deref(), Some("color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_icon_panel_can_be_dropped_into_another_docked_tab_group() {
|
||||
let mut h = Harness::new();
|
||||
h.drag(h.icons()[0].center(), pos2(500.0, 400.0));
|
||||
assert_eq!(h.groups(), [["color"]]);
|
||||
h.drag(h.shared_tab("color").center(), h.shared_tab("layers").center());
|
||||
assert!(h.groups().is_empty());
|
||||
assert!(h.app.ui.docking.as_ref().unwrap().contains(&"color".into()));
|
||||
assert!(h.app.ui.open_panel.is_none(), "the actual shared group owns Color");
|
||||
assert_eq!(h.dock_tabs().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1204,6 +1354,68 @@ mod tests {
|
||||
assert!(h.groups().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popped_out_title_drag_cancels_on_escape_focus_loss_and_workspace_change() {
|
||||
for reason in ["escape", "focus", "workspace"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("window.panel", json!({"panel":"swatches"})).unwrap();
|
||||
h.settle();
|
||||
let original = h.app.ui.clone();
|
||||
let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap();
|
||||
let to = pos2(500.0, 400.0);
|
||||
h.hold(flyout.left_top() + vec2(20.0, 13.0), to);
|
||||
assert!(h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"swatches".into())));
|
||||
match reason {
|
||||
"escape" => h.frame(vec![Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: Default::default(),
|
||||
}]),
|
||||
"focus" => h.frame(vec![Event::WindowFocused(false)]),
|
||||
_ => {
|
||||
crate::workspaces::apply(&mut h.app.ui, &crate::workspaces::Workspace::default());
|
||||
h.frame(vec![Event::PointerMoved(pos2(650.0, 450.0))]);
|
||||
}
|
||||
}
|
||||
assert!(h.ctx.data(|d| d.get_temp::<Move>(move_id())).is_none(), "{reason}: stale gesture removed");
|
||||
assert!(h.app.ui.docking.is_none(), "{reason}: original/selected workspace retained");
|
||||
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: no stale floating group");
|
||||
if reason != "workspace" {
|
||||
assert_eq!(h.app.ui.open_panel, original.open_panel);
|
||||
}
|
||||
if reason == "focus" {
|
||||
h.frame(vec![Event::WindowFocused(true)]);
|
||||
}
|
||||
for step in 1..=3 {
|
||||
h.frame(vec![Event::PointerMoved(to + vec2(15.0 * step as f32, 8.0))]);
|
||||
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: canceled drag stays canceled on held frame {step}");
|
||||
}
|
||||
h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: true, modifiers: Default::default() }]);
|
||||
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: secondary press does not restart primary gesture");
|
||||
h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: false, modifiers: Default::default() }]);
|
||||
h.release(to);
|
||||
assert!(h.app.ui.floating_panels.is_empty(), "{reason}: release does not restart canceled gesture");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docked_tools_drag_cancellation_restores_the_docked_origin() {
|
||||
let mut h = Harness::new();
|
||||
let zone = h.temp_rect(tools_zone_id());
|
||||
let title = zone.left_top() + vec2(30.0, 7.0);
|
||||
let to = pos2(400.0, 300.0);
|
||||
h.hold(title, to);
|
||||
assert!(h.app.ui.toolbar_pos.is_some());
|
||||
h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
|
||||
assert_eq!(h.app.ui.toolbar_pos, None, "cancel restores docked Tools origin");
|
||||
h.frame(vec![Event::PointerMoved(to + vec2(30.0, 20.0))]);
|
||||
assert_eq!(h.app.ui.toolbar_pos, None);
|
||||
h.release(to);
|
||||
assert_eq!(h.app.ui.toolbar_pos, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() {
|
||||
let mut h = Harness::new();
|
||||
|
||||
@@ -695,6 +695,14 @@
|
||||
Ocean Ripple… Vlnky na hladině…
|
||||
Rename Data Set Přejmenovat sadu dat
|
||||
Rename Variable Přejmenovat proměnnou
|
||||
# Shared panel docking
|
||||
Activate Panel Aktivovat panel
|
||||
Change Panel Layout Změnit rozložení panelů
|
||||
Close Panel Zavřít panel
|
||||
Move Panel Přesunout panel
|
||||
Group with Seskupit s
|
||||
Resize panels Změnit velikost panelů
|
||||
Resize panel window Změnit velikost okna panelu
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Osvětlení
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3321,6 +3321,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg `color` must be a colour `color` muss eine Farbe sein
|
||||
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` muss ein Befehl sein, dessen Menüeintrag ein Dialogfeld öffnet (siehe ui.menuDialog)
|
||||
@msg `freeform.points` must be an array `freeform.points` muss ein Array sein
|
||||
@msg `freeform.lines` must be an array `freeform.lines` muss ein Array sein
|
||||
@msg `gradient` must be an object `gradient` muss ein Objekt sein
|
||||
@msg `library` is a library id or name (see {list}) `library` ist die ID oder der Name einer Bibliothek (siehe {list})
|
||||
@msg `mode` applies to freeform gradients `mode` gilt für Freiformverläufe
|
||||
@@ -3422,6 +3423,7 @@ theme Appearance Erscheinungsbild
|
||||
@msg empty path leerer Pfad
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) die Dateiversion {0} ist neuer, als dieses VectorCraft unterstützt ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` Freiformpunkt {i} braucht `at` [x, y] und eine gültige `color`
|
||||
@msg freeform line {i} must be an array of point indices Freiformlinie {i} muss ein Array von Punktindizes sein
|
||||
@msg give both `start` and `end` [x, y] (or neither) geben Sie sowohl `start` als auch `end` [x, y] an (oder keines von beiden)
|
||||
@msg give corners of the path: anchors between two straight sides geben Sie Ecken des Pfads an: Ankerpunkte zwischen zwei geraden Seiten
|
||||
@msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an
|
||||
@@ -3852,6 +3854,7 @@ theme Appearance Erscheinungsbild
|
||||
ICF Bottom/Left ICF unten/links
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` ist der Schrittverweis {r}, den nur `run` und `command_batch` auflösen
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` muss ein Array von Objekt-IDs sein, nicht {v}
|
||||
@msg `corners` must be an array of anchor indices, not {v} `corners` muss ein Array von Ankerpunkt-Indizes sein, nicht {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: Das Ergebnis von Schritt {i} hat keinen solchen Wert (schreiben Sie $$ für ein literales $)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: Vor diesem Schritt gibt es keinen Schritt {i} (Schritte zählen ab 0; schreiben Sie $$ für ein literales $)
|
||||
@msg object ids are non-negative integers, not {v} Objekt-IDs sind nicht negative ganze Zahlen, nicht {v}
|
||||
@@ -4004,6 +4007,31 @@ theme Appearance Erscheinungsbild
|
||||
Define a variable first Definieren Sie zuerst eine Variable
|
||||
Text Text
|
||||
Visibility Sichtbarkeit
|
||||
|
||||
Activate Panel Bedienfeld aktivieren
|
||||
Change Panel Layout Bedienfeldanordnung ändern
|
||||
Close Panel Bedienfeld schließen
|
||||
Move Panel Bedienfeld verschieben
|
||||
Group with Gruppieren mit
|
||||
Resize panel window Bedienfeldfenster skalieren
|
||||
Resize panels Bedienfelder skalieren
|
||||
@msg Invalid panel layout; restored the default dock Ungültige Bedienfeldanordnung; Standarddock wiederhergestellt
|
||||
@msg action is required Eine Aktion ist erforderlich
|
||||
@msg anchor must name a registered panel Der Anker muss ein registriertes Bedienfeld benennen
|
||||
@msg before must name a panel before muss ein Bedienfeld benennen
|
||||
@msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad
|
||||
@msg group must be true or false group muss true oder false sein
|
||||
@msg move requires a destination panel Das Verschieben erfordert ein Zielbedienfeld
|
||||
@msg move requires an existing destination panel Das Verschieben erfordert ein vorhandenes Zielbedienfeld
|
||||
@msg onto must name a floating panel onto muss ein schwebendes Bedienfeld benennen
|
||||
@msg panel group is empty Die Bedienfeldgruppe ist leer
|
||||
@msg panel must name a registered panel panel muss ein registriertes Bedienfeld benennen
|
||||
@msg saved panel layout is invalid; reset the workspace first Die gespeicherte Bedienfeldanordnung ist ungültig; zuerst den Arbeitsbereich zurücksetzen
|
||||
@msg x and y must be provided together x und y müssen zusammen angegeben werden
|
||||
@msg zone must be a string zone muss eine Zeichenfolge sein
|
||||
@msg zone must be tab, center, left, right, top or bottom zone muss tab, center, left, right, top oder bottom sein
|
||||
@msg {key} must be a finite coordinate {key} muss eine endliche Koordinate sein
|
||||
@msg invalid restored panel group Ungültige wiederhergestellte Bedienfeldgruppe
|
||||
# 3D and Materials › Revolve: its options dialog and gizmos.
|
||||
Ambient Umgebungslicht
|
||||
Axis side Achsenseite
|
||||
@@ -4057,3 +4085,6 @@ theme Appearance Erscheinungsbild
|
||||
Stroke Pressure Strichdruck
|
||||
Right Diagonal Diagonal nach rechts
|
||||
Left Diagonal Diagonal nach links
|
||||
@msg invalid native panel set Ungültiger nativer Bedienfeldsatz
|
||||
@msg missing panel layout Bedienfeldanordnung fehlt
|
||||
@msg unsupported native docking operation Nicht unterstützte native Docking-Operation
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3422,6 +3422,7 @@ axis Both Ambos
|
||||
@msg `color` must be a colour `color` debe ser un color
|
||||
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` debe ser un comando cuyo elemento de menú abra un cuadro de diálogo (consulta ui.menuDialog)
|
||||
@msg `freeform.points` must be an array `freeform.points` debe ser una lista
|
||||
@msg `freeform.lines` must be an array `freeform.lines` debe ser una lista
|
||||
@msg `gradient` must be an object `gradient` debe ser un objeto
|
||||
@msg `library` is a library id or name (see {list}) `library` es el id o el nombre de una biblioteca (consulta {list})
|
||||
@msg `mode` applies to freeform gradients `mode` se aplica a los degradados de forma libre
|
||||
@@ -3523,6 +3524,7 @@ axis Both Ambos
|
||||
@msg empty path trazado vacío
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versión de archivo {0} es más reciente de lo que admite este VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` el punto de forma libre {i} necesita `at` [x, y] y un `color` válido
|
||||
@msg freeform line {i} must be an array of point indices la línea de forma libre {i} debe ser una lista de índices de puntos
|
||||
@msg give both `start` and `end` [x, y] (or neither) indica `start` y `end` [x, y] (o ninguno de los dos)
|
||||
@msg give corners of the path: anchors between two straight sides indica esquinas del trazado: puntos de ancla entre dos lados rectos
|
||||
@msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura
|
||||
@@ -3939,6 +3941,7 @@ axis Both Ambos
|
||||
Perspective Distort Distorsión de perspectiva
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` es la referencia a un paso {r}, que solo `run` y `command_batch` resuelven
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` debe ser una lista de identificadores de objeto, no {v}
|
||||
@msg `corners` must be an array of anchor indices, not {v} `corners` debe ser una lista de índices de puntos de ancla, no {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal)
|
||||
@msg object ids are non-negative integers, not {v} los identificadores de objeto son enteros no negativos, no {v}
|
||||
@@ -4044,6 +4047,31 @@ axis Both Ambos
|
||||
Define a variable first Defina primero una variable
|
||||
Text Texto
|
||||
Visibility Visibilidad
|
||||
# Shared panel docking
|
||||
Activate Panel Activar panel
|
||||
Change Panel Layout Cambiar distribución de paneles
|
||||
Close Panel Cerrar panel
|
||||
Move Panel Mover panel
|
||||
Group with Agrupar con
|
||||
Resize panels Cambiar tamaño de los paneles
|
||||
Resize panel window Cambiar tamaño de la ventana del panel
|
||||
@msg Invalid panel layout; restored the default dock La distribución de paneles no es válida; se restauró el anclaje predeterminado
|
||||
@msg action is required Se requiere action
|
||||
@msg anchor must name a registered panel anchor debe indicar un panel registrado
|
||||
@msg before must name a panel before debe indicar un panel
|
||||
@msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga
|
||||
@msg group must be true or false group debe ser true o false
|
||||
@msg move requires a destination panel Para mover se requiere un panel de destino
|
||||
@msg move requires an existing destination panel Para mover se requiere un panel de destino existente
|
||||
@msg onto must name a floating panel onto debe indicar un panel flotante
|
||||
@msg panel group is empty El grupo de paneles está vacío
|
||||
@msg panel must name a registered panel panel debe indicar un panel registrado
|
||||
@msg saved panel layout is invalid; reset the workspace first La distribución guardada de paneles no es válida; primero restablece el espacio de trabajo
|
||||
@msg zone must be a string zone debe ser una cadena de texto
|
||||
@msg zone must be tab, center, left, right, top or bottom zone debe ser tab, center, left, right, top o bottom
|
||||
@msg {key} must be a finite coordinate {key} debe ser una coordenada finita
|
||||
@msg x and y must be provided together x e y deben proporcionarse juntos
|
||||
@msg invalid restored panel group El grupo de paneles restaurado no es válido
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
@@ -4102,3 +4130,6 @@ lighting Light Luz
|
||||
Vertical Guide Guía vertical
|
||||
Horizontal Guide Guía horizontal
|
||||
Move Guide to Layer Mover guía a capa
|
||||
@msg invalid native panel set Conjunto nativo de paneles no válido
|
||||
@msg missing panel layout Falta la distribución de paneles
|
||||
@msg unsupported native docking operation Operación nativa de anclaje no admitida
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3419,6 +3419,7 @@ axis Both Les deux
|
||||
@msg `color` must be a colour `color` doit être une couleur
|
||||
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` doit être une commande dont l’élément de menu ouvre une boîte de dialogue (voir ui.menuDialog)
|
||||
@msg `freeform.points` must be an array `freeform.points` doit être un tableau
|
||||
@msg `freeform.lines` must be an array `freeform.lines` doit être un tableau
|
||||
@msg `gradient` must be an object `gradient` doit être un objet
|
||||
@msg `library` is a library id or name (see {list}) `library` est l’identifiant ou le nom d’une bibliothèque (voir {list})
|
||||
@msg `mode` applies to freeform gradients `mode` s’applique aux dégradés de forme libre
|
||||
@@ -3520,6 +3521,7 @@ axis Both Les deux
|
||||
@msg empty path tracé vide
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la version de fichier {0} est plus récente que celles que prend en charge cette version de VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` le point de forme libre {i} nécessite `at` [x, y] et une `color` valide
|
||||
@msg freeform line {i} must be an array of point indices la ligne de forme libre {i} doit être un tableau d’indices de points
|
||||
@msg give both `start` and `end` [x, y] (or neither) indiquez à la fois `start` et `end` [x, y] (ou aucun des deux)
|
||||
@msg give corners of the path: anchors between two straight sides indiquez des angles du tracé : des points d’ancrage entre deux côtés droits
|
||||
@msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur
|
||||
@@ -3950,6 +3952,7 @@ axis Both Les deux
|
||||
ICF Bottom/Left ICF bas/gauche
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` est la référence d’étape {r}, que seuls `run` et `command_batch` résolvent
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` doit être une liste d’identifiants d’objet, pas {v}
|
||||
@msg `corners` must be an array of anchor indices, not {v} `corners` doit être une liste d’indices de points d’ancrage, pas {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral)
|
||||
@msg object ids are non-negative integers, not {v} les identifiants d’objet sont des entiers positifs ou nuls, pas {v}
|
||||
@@ -4056,6 +4059,31 @@ effect Soft Doux
|
||||
Define a variable first Définissez d’abord une variable
|
||||
Text Texte
|
||||
Visibility Visibilité
|
||||
# Shared panel docking
|
||||
Activate Panel Activer le panneau
|
||||
Change Panel Layout Modifier la disposition des panneaux
|
||||
Close Panel Fermer le panneau
|
||||
Move Panel Déplacer le panneau
|
||||
Group with Regrouper avec
|
||||
Resize panels Redimensionner les panneaux
|
||||
Resize panel window Redimensionner la fenêtre du panneau
|
||||
@msg Invalid panel layout; restored the default dock La disposition des panneaux est invalide ; la disposition ancrée par défaut a été restaurée
|
||||
@msg action is required action est requis
|
||||
@msg anchor must name a registered panel anchor doit désigner un panneau enregistré
|
||||
@msg before must name a panel before doit désigner un panneau
|
||||
@msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long
|
||||
@msg group must be true or false group doit être true ou false
|
||||
@msg move requires a destination panel Le déplacement nécessite un panneau de destination
|
||||
@msg move requires an existing destination panel Le déplacement nécessite un panneau de destination existant
|
||||
@msg onto must name a floating panel onto doit désigner un panneau flottant
|
||||
@msg panel group is empty Le groupe de panneaux est vide
|
||||
@msg panel must name a registered panel panel doit désigner un panneau enregistré
|
||||
@msg saved panel layout is invalid; reset the workspace first La disposition enregistrée des panneaux est invalide ; réinitialisez d’abord l’espace de travail
|
||||
@msg zone must be a string zone doit être une chaîne de caractères
|
||||
@msg zone must be tab, center, left, right, top or bottom zone doit être tab, center, left, right, top ou bottom
|
||||
@msg {key} must be a finite coordinate {key} doit être une coordonnée finie
|
||||
@msg x and y must be provided together x et y doivent être fournis ensemble
|
||||
@msg invalid restored panel group Le groupe de panneaux restauré est invalide
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Éclairage
|
||||
@@ -4112,3 +4140,6 @@ lighting Light Lumière
|
||||
Vertical Guide Repère vertical
|
||||
Horizontal Guide Repère horizontal
|
||||
Move Guide to Layer Déplacer le repère vers le calque
|
||||
@msg invalid native panel set Ensemble natif de panneaux invalide
|
||||
@msg missing panel layout Disposition des panneaux manquante
|
||||
@msg unsupported native docking operation Opération native d’ancrage non prise en charge
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3422,6 +3422,7 @@ axis Both Entrambi
|
||||
@msg `color` must be a colour `color` deve essere un colore
|
||||
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` deve essere un comando la cui voce di menu apre una finestra di dialogo (vedi ui.menuDialog)
|
||||
@msg `freeform.points` must be an array `freeform.points` deve essere un array
|
||||
@msg `freeform.lines` must be an array `freeform.lines` deve essere un array
|
||||
@msg `gradient` must be an object `gradient` deve essere un oggetto
|
||||
@msg `library` is a library id or name (see {list}) `library` è l'id o il nome di una libreria (vedi {list})
|
||||
@msg `mode` applies to freeform gradients `mode` si applica alle sfumature a forma libera
|
||||
@@ -3523,6 +3524,7 @@ axis Both Entrambi
|
||||
@msg empty path tracciato vuoto
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) la versione del file {0} è più recente di quelle supportate da questo VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` il punto a forma libera {i} richiede `at` [x, y] e un `color` valido
|
||||
@msg freeform line {i} must be an array of point indices la linea a forma libera {i} deve essere un array di indici di punti
|
||||
@msg give both `start` and `end` [x, y] (or neither) specifica sia `start` sia `end` [x, y] (o nessuno dei due)
|
||||
@msg give corners of the path: anchors between two straight sides specifica angoli del tracciato: punti di ancoraggio tra due lati diritti
|
||||
@msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza
|
||||
@@ -3939,6 +3941,7 @@ axis Both Entrambi
|
||||
Perspective Distort Distorsione prospettica
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` è il riferimento al passaggio {r}, che solo `run` e `command_batch` risolvono
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` deve essere un elenco di ID oggetto, non {v}
|
||||
@msg `corners` must be an array of anchor indices, not {v} `corners` deve essere un elenco di indici di punti di ancoraggio, non {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale)
|
||||
@msg object ids are non-negative integers, not {v} gli ID oggetto sono interi non negativi, non {v}
|
||||
@@ -4046,6 +4049,31 @@ effect Regular Normale
|
||||
Define a variable first Definisci prima una variabile
|
||||
Text Testo
|
||||
Visibility Visibilità
|
||||
# Shared panel docking
|
||||
Activate Panel Attiva pannello
|
||||
Change Panel Layout Modifica disposizione dei pannelli
|
||||
Close Panel Chiudi pannello
|
||||
Move Panel Sposta pannello
|
||||
Group with Raggruppa con
|
||||
Resize panels Ridimensiona pannelli
|
||||
Resize panel window Ridimensiona finestra del pannello
|
||||
@msg Invalid panel layout; restored the default dock La disposizione dei pannelli non è valida; è stato ripristinato l’ancoraggio predefinito
|
||||
@msg action is required action è obbligatorio
|
||||
@msg anchor must name a registered panel anchor deve indicare un pannello registrato
|
||||
@msg before must name a panel before deve indicare un pannello
|
||||
@msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo
|
||||
@msg group must be true or false group deve essere true o false
|
||||
@msg move requires a destination panel Lo spostamento richiede un pannello di destinazione
|
||||
@msg move requires an existing destination panel Lo spostamento richiede un pannello di destinazione esistente
|
||||
@msg onto must name a floating panel onto deve indicare un pannello mobile
|
||||
@msg panel group is empty Il gruppo di pannelli è vuoto
|
||||
@msg panel must name a registered panel panel deve indicare un pannello registrato
|
||||
@msg saved panel layout is invalid; reset the workspace first La disposizione salvata dei pannelli non è valida; reimposta prima l’area di lavoro
|
||||
@msg zone must be a string zone deve essere una stringa
|
||||
@msg zone must be tab, center, left, right, top or bottom zone deve essere tab, center, left, right, top o bottom
|
||||
@msg {key} must be a finite coordinate {key} deve essere una coordinata finita
|
||||
@msg x and y must be provided together x e y devono essere forniti insieme
|
||||
@msg invalid restored panel group Il gruppo di pannelli ripristinato non è valido
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
@@ -4104,3 +4132,6 @@ lighting Light Luce
|
||||
Vertical Guide Guida verticale
|
||||
Horizontal Guide Guida orizzontale
|
||||
Move Guide to Layer Sposta guida nel livello
|
||||
@msg invalid native panel set Gruppo nativo di pannelli non valido
|
||||
@msg missing panel layout Disposizione dei pannelli mancante
|
||||
@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3471,9 +3471,11 @@ theme Appearance 外観
|
||||
@msg `at` must be [x, y] `at` は [x, y] の形式で指定してください
|
||||
@msg `color` must be a colour `color` にはカラーを指定してください
|
||||
@msg `freeform.points` must be an array `freeform.points` には配列を指定してください
|
||||
@msg `freeform.lines` must be an array `freeform.lines` には配列を指定してください
|
||||
@msg `{key}` must be a number `{key}` には数値を指定してください
|
||||
@msg a freeform gradient keeps at least one point フリーフォームグラデーションには少なくとも 1 つのポイントが必要です
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` フリーフォームのポイント {i} には `at` [x, y] と有効な `color` が必要です
|
||||
@msg freeform line {i} must be an array of point indices フリーフォームのライン {i} にはポイントのインデックスの配列を指定してください
|
||||
@msg no point {i} (the gradient has {_1}) ポイント {i} はありません (このグラデーションのポイントは {_1} 個です)
|
||||
@msg no segment {segment} on line {line} ライン {line} にセグメント {segment} はありません
|
||||
@msg unknown freeform mode `{m}` (points, lines) `{m}` は不明なフリーフォームのモードです (points、lines)
|
||||
@@ -3932,6 +3934,7 @@ theme Appearance 外観
|
||||
@msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません)
|
||||
@msg `corners` must be an array of anchor indices, not {v} `corners` はアンカーポイントのインデックスの配列にしてください ({v} は使えません)
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください)
|
||||
@msg object ids are non-negative integers, not {v} オブジェクト ID は 0 以上の整数です ({v} は使えません)
|
||||
@@ -4039,6 +4042,32 @@ effect Vertical 垂直
|
||||
Define a variable first 先に変数を定義してください
|
||||
Text テキスト
|
||||
Visibility 表示状態
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# Shared panel docking
|
||||
Activate Panel パネルをアクティブにする
|
||||
Change Panel Layout パネルの配置を変更
|
||||
Close Panel パネルを閉じる
|
||||
Move Panel パネルを移動
|
||||
Group with グループ化先
|
||||
Resize panels パネルのサイズを変更
|
||||
Resize panel window パネルウィンドウのサイズを変更
|
||||
@msg invalid restored panel group 復元されたパネルグループが無効です
|
||||
@msg Invalid panel layout; restored the default dock パネル配置が無効です。既定のドックを復元しました
|
||||
@msg action is required action を指定してください
|
||||
@msg anchor must name a registered panel anchor に登録済みパネルを指定してください
|
||||
@msg before must name a panel before にパネルを指定してください
|
||||
@msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています
|
||||
@msg group must be true or false group は true または false にしてください
|
||||
@msg move requires a destination panel 移動先のパネルを指定してください
|
||||
@msg move requires an existing destination panel 移動先には既存のパネルを指定してください
|
||||
@msg onto must name a floating panel onto にフローティングパネルを指定してください
|
||||
@msg panel group is empty パネルグループが空です
|
||||
@msg panel must name a registered panel panel に登録済みパネルを指定してください
|
||||
@msg saved panel layout is invalid; reset the workspace first 保存されたパネル配置が無効です。先にワークスペースをリセットしてください
|
||||
@msg x and y must be provided together x と y は両方指定してください
|
||||
@msg zone must be a string zone は文字列にしてください
|
||||
@msg zone must be tab, center, left, right, top or bottom zone は tab、center、left、right、top、bottom のいずれかにしてください
|
||||
@msg {key} must be a finite coordinate {key} は有限の座標にしてください
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
@@ -4097,3 +4126,6 @@ lighting Light ライト
|
||||
Vertical Guide 垂直ガイド
|
||||
Horizontal Guide 水平ガイド
|
||||
Move Guide to Layer ガイドをレイヤーへ移動
|
||||
@msg invalid native panel set ネイティブパネルセットが無効です
|
||||
@msg missing panel layout パネル配置がありません
|
||||
@msg unsupported native docking operation ネイティブドッキング操作は未対応です
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2756,6 +2756,14 @@
|
||||
Ocean Ripple… Ondulação oceânica…
|
||||
Rename Data Set Renomear conjunto de dados
|
||||
Rename Variable Renomear variável
|
||||
# Shared panel docking
|
||||
Activate Panel Ativar Painel
|
||||
Change Panel Layout Alterar Layout dos Painéis
|
||||
Close Panel Fechar Painel
|
||||
Move Panel Mover Painel
|
||||
Group with Agrupar com
|
||||
Resize panels Redimensionar painéis
|
||||
Resize panel window Redimensionar janela do painel
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Iluminação
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3418,6 +3418,7 @@ axis Both Оба
|
||||
@msg `color` must be a colour `color` должно быть цветом
|
||||
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` должно быть командой, пункт меню которой открывает диалоговое окно (см. ui.menuDialog)
|
||||
@msg `freeform.points` must be an array `freeform.points` должно быть массивом
|
||||
@msg `freeform.lines` must be an array `freeform.lines` должно быть массивом
|
||||
@msg `gradient` must be an object `gradient` должно быть объектом
|
||||
@msg `library` is a library id or name (see {list}) `library` — это идентификатор или имя библиотеки (см. {list})
|
||||
@msg `mode` applies to freeform gradients `mode` применяется к произвольным градиентам
|
||||
@@ -3519,6 +3520,7 @@ axis Both Оба
|
||||
@msg empty path пустой контур
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) версия файла {0} новее, чем поддерживает этот VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` произвольной точке {i} нужны `at` [x, y] и допустимый `color`
|
||||
@msg freeform line {i} must be an array of point indices произвольная линия {i} должна быть массивом индексов точек
|
||||
@msg give both `start` and `end` [x, y] (or neither) укажите и `start`, и `end` [x, y] (или ни одного)
|
||||
@msg give corners of the path: anchors between two straight sides укажите углы контура: опорные точки между двумя прямыми сторонами
|
||||
@msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины
|
||||
@@ -3937,6 +3939,7 @@ axis Both Оба
|
||||
Exit Выйти
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — это ссылка на шаг {r}, которую разрешают только `run` и `command_batch`
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` должен быть массивом идентификаторов объектов, а не {v}
|
||||
@msg `corners` must be an array of anchor indices, not {v} `corners` должен быть массивом индексов опорных точек, а не {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$)
|
||||
@msg object ids are non-negative integers, not {v} идентификаторы объектов — неотрицательные целые числа, а не {v}
|
||||
@@ -4042,6 +4045,31 @@ axis Both Оба
|
||||
Define a variable first Сначала определите переменную
|
||||
Text Текст
|
||||
Visibility Видимость
|
||||
# Shared panel docking
|
||||
Activate Panel Активировать панель
|
||||
Change Panel Layout Изменить расположение панелей
|
||||
Close Panel Закрыть панель
|
||||
Move Panel Переместить панель
|
||||
Group with Сгруппировать с
|
||||
Resize panels Изменить размер панелей
|
||||
Resize panel window Изменить размер окна панели
|
||||
@msg Invalid panel layout; restored the default dock Недопустимое расположение панелей; восстановлено расположение по умолчанию
|
||||
@msg action is required Необходимо указать action
|
||||
@msg anchor must name a registered panel anchor должен указывать зарегистрированную панель
|
||||
@msg before must name a panel before должен указывать панель
|
||||
@msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений
|
||||
@msg group must be true or false group должен иметь значение true или false
|
||||
@msg move requires a destination panel Для перемещения требуется целевая панель
|
||||
@msg move requires an existing destination panel Для перемещения требуется существующая целевая панель
|
||||
@msg onto must name a floating panel onto должен указывать плавающую панель
|
||||
@msg panel group is empty Группа панелей пуста
|
||||
@msg panel must name a registered panel panel должен указывать зарегистрированную панель
|
||||
@msg saved panel layout is invalid; reset the workspace first Сохранённое расположение панелей недопустимо; сначала сбросьте рабочую среду
|
||||
@msg zone must be a string zone должен быть строкой
|
||||
@msg zone must be tab, center, left, right, top or bottom zone должен иметь значение tab, center, left, right, top или bottom
|
||||
@msg {key} must be a finite coordinate {key} должен быть конечной координатой
|
||||
@msg x and y must be provided together x и y необходимо указывать вместе
|
||||
@msg invalid restored panel group Восстановленная группа панелей недопустима
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
@@ -4100,3 +4128,6 @@ lighting Light Свет
|
||||
Vertical Guide Вертикальная направляющая
|
||||
Horizontal Guide Горизонтальная направляющая
|
||||
Move Guide to Layer Переместить направляющую на слой
|
||||
@msg invalid native panel set Недопустимый собственный набор панелей
|
||||
@msg missing panel layout Отсутствует расположение панелей
|
||||
@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3405,6 +3405,7 @@ axis Both Обидві
|
||||
@msg `color` must be a colour `color` має бути кольором
|
||||
@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `command` має бути командою, пункт меню якої відкриває діалогове вікно (див. ui.menuDialog)
|
||||
@msg `freeform.points` must be an array `freeform.points` має бути масивом
|
||||
@msg `freeform.lines` must be an array `freeform.lines` має бути масивом
|
||||
@msg `gradient` must be an object `gradient` має бути об’єктом
|
||||
@msg `library` is a library id or name (see {list}) `library` — це ідентифікатор або ім’я бібліотеки (див. {list})
|
||||
@msg `mode` applies to freeform gradients `mode` застосовується до довільних градієнтів
|
||||
@@ -3506,6 +3507,7 @@ axis Both Обидві
|
||||
@msg empty path порожній контур
|
||||
@msg file version {0} is newer than this VectorCraft supports ({VERSION}) версія файлу {0} новіша, ніж підтримує цей VectorCraft ({VERSION})
|
||||
@msg freeform point {i} needs `at` [x, y] and a valid `color` довільній точці {i} потрібні `at` [x, y] та допустимий `color`
|
||||
@msg freeform line {i} must be an array of point indices довільна лінія {i} має бути масивом індексів точок
|
||||
@msg give both `start` and `end` [x, y] (or neither) вкажіть `start`, `end` [x, y] (або жодного)
|
||||
@msg give corners of the path: anchors between two straight sides вкажіть кути контуру: опорні точки між двома прямими сторонами
|
||||
@msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини
|
||||
@@ -3922,6 +3924,7 @@ axis Both Обидві
|
||||
Exit Вийти
|
||||
@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — це посилання на крок {r}, яке розв’язують лише `run` і `command_batch`
|
||||
@msg `ids` must be an array of object ids, not {v} `ids` має бути масивом ідентифікаторів об’єктів, а не {v}
|
||||
@msg `corners` must be an array of anchor indices, not {v} `corners` має бути масивом індексів опорних точок, а не {v}
|
||||
@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$)
|
||||
@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$)
|
||||
@msg object ids are non-negative integers, not {v} ідентифікатори об’єктів — невід’ємні цілі числа, а не {v}
|
||||
@@ -4027,6 +4030,31 @@ axis Both Обидві
|
||||
Define a variable first Спочатку визначте змінну
|
||||
Text Текст
|
||||
Visibility Видимість
|
||||
# Shared panel docking
|
||||
Activate Panel Активувати панель
|
||||
Change Panel Layout Змінити розташування панелей
|
||||
Close Panel Закрити панель
|
||||
Move Panel Перемістити панель
|
||||
Group with Згрупувати з
|
||||
Resize panels Змінити розмір панелей
|
||||
Resize panel window Змінити розмір вікна панелі
|
||||
@msg Invalid panel layout; restored the default dock Неприпустиме розташування панелей; відновлено типове розташування
|
||||
@msg action is required Потрібно вказати action
|
||||
@msg anchor must name a registered panel anchor має вказувати зареєстровану панель
|
||||
@msg before must name a panel before має вказувати панель
|
||||
@msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів
|
||||
@msg group must be true or false group має мати значення true або false
|
||||
@msg move requires a destination panel Для переміщення потрібна цільова панель
|
||||
@msg move requires an existing destination panel Для переміщення потрібна наявна цільова панель
|
||||
@msg onto must name a floating panel onto має вказувати плаваючу панель
|
||||
@msg panel group is empty Група панелей порожня
|
||||
@msg panel must name a registered panel panel має вказувати зареєстровану панель
|
||||
@msg saved panel layout is invalid; reset the workspace first Збережене розташування панелей неприпустиме; спочатку скиньте робоче середовище
|
||||
@msg zone must be a string zone має бути рядком
|
||||
@msg zone must be tab, center, left, right, top or bottom zone має мати значення tab, center, left, right, top або bottom
|
||||
@msg {key} must be a finite coordinate {key} має бути скінченною координатою
|
||||
@msg x and y must be provided together x та y потрібно вказувати разом
|
||||
@msg invalid restored panel group Відновлена група панелей неприпустима
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Освітлення
|
||||
@@ -4083,3 +4111,6 @@ lighting Light Світло
|
||||
Vertical Guide Вертикальна напрямна
|
||||
Horizontal Guide Горизонтальна напрямна
|
||||
Move Guide to Layer Перемістити напрямну на шар
|
||||
@msg invalid native panel set Неприпустимий власний набір панелей
|
||||
@msg missing panel layout Відсутнє розташування панелей
|
||||
@msg unsupported native docking operation Непідтримувана власна операція прикріплення
|
||||
|
Can't render this file because it contains an unexpected character in line 659 and column 40.
|
@@ -3670,6 +3670,14 @@ effect Vertical 垂直
|
||||
Define a variable first 请先定义一个变量
|
||||
Text 文本
|
||||
Visibility 可见性
|
||||
# Shared panel docking
|
||||
Activate Panel 激活面板
|
||||
Change Panel Layout 更改面板布局
|
||||
Close Panel 关闭面板
|
||||
Move Panel 移动面板
|
||||
Group with 组合到
|
||||
Resize panels 调整面板大小
|
||||
Resize panel window 调整面板窗口大小
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3451,6 +3451,14 @@ effect Soft 柔和
|
||||
Define a variable first 請先定義一個變數
|
||||
Text 文字
|
||||
Visibility 可見性
|
||||
# Shared panel docking
|
||||
Activate Panel 啟用面板
|
||||
Change Panel Layout 變更面板配置
|
||||
Close Panel 關閉面板
|
||||
Move Panel 移動面板
|
||||
Group with 群組至
|
||||
Resize panels 調整面板大小
|
||||
Resize panel window 調整面板視窗大小
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
+37
-17
@@ -1,28 +1,19 @@
|
||||
//! Icon lookup and drawing (SVGs rasterized by egui_extras, tinted with theme colours).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use craft_ui::icons::{SvgIconSet, SvgStyle};
|
||||
use egui::{Color32, ImageSource, Rect, Ui, Vec2};
|
||||
|
||||
fn table() -> &'static HashMap<&'static str, &'static [u8]> {
|
||||
static T: OnceLock<HashMap<&'static str, &'static [u8]>> = OnceLock::new();
|
||||
fn table() -> &'static SvgIconSet {
|
||||
static T: OnceLock<SvgIconSet> = OnceLock::new();
|
||||
T.get_or_init(|| {
|
||||
crate::icon_data::ICONS
|
||||
.iter()
|
||||
.map(|(n, b)| {
|
||||
// Icons use currentColor; render white and tint at draw time. Slightly thinner strokes
|
||||
// than Lucide's default read closer to Illustrator's glyph weight.
|
||||
let s = String::from_utf8_lossy(b).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
|
||||
let leaked: &'static [u8] = Box::leak(s.into_bytes().into_boxed_slice());
|
||||
(*n, leaked)
|
||||
})
|
||||
.collect()
|
||||
SvgIconSet::new(crate::icon_data::ICONS, SvgStyle { current_color: "white", stroke_width: Some("1.6") }, "bytes://icon/", "square-dashed")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn exists(name: &str) -> bool {
|
||||
table().contains_key(name)
|
||||
table().contains(name)
|
||||
}
|
||||
|
||||
/// Map tool-catalogue icon names to SVG files.
|
||||
@@ -102,7 +93,7 @@ pub fn tool_icon(name: &str) -> &'static str {
|
||||
"tool-zoom" => "zoom-in",
|
||||
other => {
|
||||
if exists(other) {
|
||||
table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed")
|
||||
table().canonical_name(other).unwrap_or("square-dashed")
|
||||
} else {
|
||||
"square-dashed"
|
||||
}
|
||||
@@ -111,8 +102,7 @@ pub fn tool_icon(name: &str) -> &'static str {
|
||||
}
|
||||
|
||||
pub fn source(name: &str) -> ImageSource<'static> {
|
||||
let bytes = table().get(name).or_else(|| table().get("square-dashed")).copied().unwrap_or(&[]);
|
||||
ImageSource::Bytes { uri: format!("bytes://icon/{name}.svg").into(), bytes: egui::load::Bytes::Static(bytes) }
|
||||
table().source(name)
|
||||
}
|
||||
|
||||
/// Paint icon `name` into `rect` tinted with `tint`.
|
||||
@@ -126,3 +116,33 @@ pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response
|
||||
paint(ui, name, rect, tint);
|
||||
resp
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Characterize the pre-extraction loader against every embedded asset, including custom
|
||||
/// icons. A shared-crate update must not silently change our stroke weight or cache keys.
|
||||
#[test]
|
||||
fn shared_catalog_preserves_existing_icons() {
|
||||
for (name, original) in crate::icon_data::ICONS {
|
||||
let expected = String::from_utf8_lossy(original).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\"");
|
||||
let ImageSource::Bytes { uri, bytes } = source(name) else { panic!("expected SVG bytes") };
|
||||
assert_eq!(uri.as_ref(), format!("bytes://icon/{name}.svg"));
|
||||
assert_eq!(bytes.as_ref(), expected.as_bytes(), "{name}");
|
||||
assert!(exists(name));
|
||||
assert_eq!(tool_icon(name), *name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_catalog_preserves_fallback_and_tool_aliases() {
|
||||
let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") };
|
||||
let ImageSource::Bytes { bytes: fallback, .. } = source("square-dashed") else { panic!("expected SVG bytes") };
|
||||
assert_eq!(uri.as_ref(), "bytes://icon/missing-icon.svg");
|
||||
assert_eq!(bytes.as_ref(), fallback.as_ref());
|
||||
assert!(!exists("missing-icon"));
|
||||
assert_eq!(tool_icon("missing-icon"), "square-dashed");
|
||||
assert_eq!(tool_icon("tool-selection"), "dc-selection");
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ pub mod io;
|
||||
pub mod menus;
|
||||
pub mod native_menu;
|
||||
pub mod palette;
|
||||
mod panel_docking;
|
||||
pub mod panels;
|
||||
pub mod picks;
|
||||
pub mod place;
|
||||
|
||||
@@ -256,6 +256,20 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"),
|
||||
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
|
||||
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
|
||||
(
|
||||
"window.panel.layout",
|
||||
"Change Panel Layout",
|
||||
"",
|
||||
"{action} serialized docking action, including floating geometry, divider sizes and accordion state",
|
||||
),
|
||||
(
|
||||
"window.panel.move",
|
||||
"Move Panel",
|
||||
"",
|
||||
"{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} group, split or reorder a panel",
|
||||
),
|
||||
("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"),
|
||||
("window.panel.close", "Close Panel", "", "{panel} hide a panel"),
|
||||
(
|
||||
"window.panel.float",
|
||||
"Float Panel",
|
||||
@@ -843,6 +857,9 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result<Option<bool>, String> {
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if let Some(result) = crate::panel_docking::command(app, id, p) {
|
||||
return Some(result);
|
||||
}
|
||||
if id == "app.language" {
|
||||
let lang = p.get("lang").and_then(Value::as_str).unwrap_or("");
|
||||
let value = if lang.eq_ignore_ascii_case("auto") {
|
||||
@@ -1449,6 +1466,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
|
||||
let canonical = normalize_panel(panel);
|
||||
match canonical.and_then(DockTab::from_id) {
|
||||
_ if app.ui.docking.is_some() => canonical.is_some_and(|id| {
|
||||
app.ui.open_panel.as_deref() == Some(id) || app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string()))
|
||||
}),
|
||||
_ if canonical.is_some_and(|id| crate::floating::group_of(&app.ui, id).is_some()) => true,
|
||||
Some(tab) if !app.ui.dock_collapsed => crate::floating::shown_tab(&app.ui) == Some(tab),
|
||||
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),
|
||||
|
||||
@@ -0,0 +1,991 @@
|
||||
//! Application-owned panel IDs and commands around the shared docking layout.
|
||||
|
||||
use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct LegacyDrag {
|
||||
layout: Option<Layout<String>>,
|
||||
open_panel: Option<String>,
|
||||
floating: Vec<crate::state::FloatingPanels>,
|
||||
icons: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RootGroupDrag {
|
||||
panel: String,
|
||||
layout: Layout<String>,
|
||||
hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
let key = egui::Id::new("vectorcraft-panel-docking").with("legacy-origin");
|
||||
let previous = ctx.data_mut(|data| data.remove_temp::<LegacyDrag>(key));
|
||||
if let Some(previous) = previous {
|
||||
app.ui.open_panel = previous.open_panel;
|
||||
app.ui.floating_panels = previous.floating;
|
||||
app.ui.docking = previous.layout;
|
||||
app.ui.docking_icons = previous.icons;
|
||||
craft_ui::docking::cancel_drag::<String>(ctx, egui::Id::new("vectorcraft-panel-docking"));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(raw: &str) -> Option<&'static str> {
|
||||
crate::menus::normalize_panel(raw)
|
||||
}
|
||||
|
||||
pub(crate) fn valid(layout: &Layout<String>) -> bool {
|
||||
layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str()))
|
||||
}
|
||||
|
||||
fn legacy(app: &VectorcraftApp) -> Layout<String> {
|
||||
let panels: Vec<String> = crate::floating::docked_tabs(&app.ui).map(|tab| tab.info().0.to_string()).collect();
|
||||
let active = panels.iter().position(|id| id == app.ui.dock_tab.info().0).unwrap_or(0);
|
||||
let root = (!panels.is_empty()).then_some(Node::Tabs { panels, active });
|
||||
let mut layout = Layout { root, floating: Vec::new() };
|
||||
for group in &app.ui.floating_panels {
|
||||
let panels: Vec<String> = group.panels.iter().filter(|id| normalize(id).is_some() && !layout.contains(id)).cloned().collect();
|
||||
if !panels.is_empty() && group.pos.iter().all(|v| v.is_finite()) {
|
||||
let active = group.active.min(panels.len().saturating_sub(1));
|
||||
layout.floating.push(Floating {
|
||||
panels,
|
||||
active,
|
||||
rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0).clamp(200.0, 720.0), 400.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
fn icon_origins(app: &VectorcraftApp) -> std::collections::BTreeSet<String> {
|
||||
let mut icons = app.ui.docking_icons.clone();
|
||||
if app.ui.docking.is_none() {
|
||||
// Legacy workspaces have no custom groups in the dock. Icon-panel floats therefore
|
||||
// retain their native rail destination when first converted to a shared layout.
|
||||
icons.extend(
|
||||
app.ui
|
||||
.floating_panels
|
||||
.iter()
|
||||
.flat_map(|group| group.panels.iter())
|
||||
.filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str()))
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
icons
|
||||
}
|
||||
|
||||
fn first_root(layout: &Layout<String>, except: &str) -> Option<String> {
|
||||
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
|
||||
root.panels().into_iter().find(|p| p.as_str() != except).cloned()
|
||||
}
|
||||
|
||||
// Preserve the native dock's selected tab when another tab leaves. If the selected tab
|
||||
// itself leaves, the remaining group's first tab is the native fallback.
|
||||
fn float_fallback(layout: &Layout<String>, panel: &str) -> Option<String> {
|
||||
let mut pending: Vec<_> = layout.root.iter().collect();
|
||||
while let Some(node) = pending.pop() {
|
||||
match node {
|
||||
Node::Tabs { panels, active } if panels.iter().any(|id| id == panel) => {
|
||||
return panels.get(*active).filter(|id| id.as_str() != panel).or_else(|| panels.iter().find(|id| id.as_str() != panel)).cloned();
|
||||
}
|
||||
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn restore_float_fallback(layout: &mut Layout<String>, panel: Option<String>) -> Result<(), String> {
|
||||
if let Some(panel) = panel.filter(|panel| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(panel)) {
|
||||
layout.apply(Action::Activate { panel }).map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure(layout: &mut Layout<String>, panel: &str) -> Result<(), String> {
|
||||
let anchor = first_root(layout, panel);
|
||||
layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn group_members(layout: &Layout<String>, panel: &str) -> Vec<String> {
|
||||
if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|id| id == panel)) {
|
||||
return group.panels.clone();
|
||||
}
|
||||
let mut visit: Vec<_> = layout.root.iter().collect();
|
||||
while let Some(node) = visit.pop() {
|
||||
match node {
|
||||
Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return panels.clone(),
|
||||
Node::Split { first, second, .. } => {
|
||||
visit.push(second);
|
||||
visit.push(first);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
vec![panel.into()]
|
||||
}
|
||||
|
||||
fn number(params: &Value, key: &str, default: f32) -> Result<f32, String> {
|
||||
match params.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && v.abs() <= 1_000_000.0)
|
||||
.map(|v| v as f32)
|
||||
.ok_or_else(|| format!("{key} must be a finite coordinate")),
|
||||
}
|
||||
}
|
||||
|
||||
fn placement(params: &Value) -> Result<Placement<String>, String> {
|
||||
if let Some(before) = params.get("before") {
|
||||
let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?;
|
||||
return Ok(Placement::Tab { before: Some(id.into()) });
|
||||
}
|
||||
let zone = match params.get("zone") {
|
||||
None => "tab",
|
||||
Some(value) => value.as_str().ok_or("zone must be a string")?,
|
||||
};
|
||||
Ok(match zone {
|
||||
"tab" => Placement::Tab { before: None },
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be tab, center, left, right, top or bottom".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "window.panel.float" | "window.panel.dock")
|
||||
&& (params.get("above").is_some()
|
||||
|| params.get("below").is_some()
|
||||
|| params.get("column").is_some()
|
||||
|| params.get("collapsed").is_some()
|
||||
|| params
|
||||
.get("onto")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize)
|
||||
.and_then(|id| crate::floating::group_of(&app.ui, id))
|
||||
.and_then(|i| app.ui.floating_panels.get(i))
|
||||
.is_some_and(decorated)
|
||||
|| params
|
||||
.get("panel")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize)
|
||||
.and_then(|id| crate::floating::group_of(&app.ui, id))
|
||||
.and_then(|i| app.ui.floating_panels.get(i))
|
||||
.is_some_and(decorated))
|
||||
{
|
||||
return Some(native_change(app, id, params));
|
||||
}
|
||||
if id == "window.panel.layout" {
|
||||
return Some(
|
||||
params
|
||||
.get("action")
|
||||
.ok_or_else(|| "action is required".to_string())
|
||||
.and_then(|value| serde_json::from_value::<Action<String>>(value.clone()).map_err(|error| error.to_string()))
|
||||
.and_then(|action| apply_action(app, action)),
|
||||
);
|
||||
}
|
||||
if params.get("panel").and_then(Value::as_str).is_some_and(|s| s.eq_ignore_ascii_case("tools") || s.eq_ignore_ascii_case("toolbar")) {
|
||||
return None;
|
||||
}
|
||||
let operation = match id {
|
||||
"window.panel.move" => "move",
|
||||
"window.panel.float" => "float",
|
||||
"window.panel.dock" => "dock",
|
||||
"window.panel.activate" => "activate",
|
||||
"window.panel.close" => "close",
|
||||
"window.panel" if app.ui.docking.is_some() => "activate",
|
||||
_ => return None,
|
||||
};
|
||||
Some(change(app, operation, params).map(|value| {
|
||||
if id == "window.panel"
|
||||
&& let Some(panel) = params.get("panel").and_then(Value::as_str).and_then(normalize)
|
||||
&& let Some(group) = app.ui.floating_panels.iter().find(|group| group.panels.iter().any(|id| id == panel))
|
||||
{
|
||||
return json!({"floating":group.panels});
|
||||
}
|
||||
value
|
||||
}))
|
||||
}
|
||||
|
||||
// UI output and automation share this atomic dispatcher. The application owns the registry
|
||||
// and saved return locations; craft-ui owns tree validation and structural changes.
|
||||
fn apply_action(app: &mut VectorcraftApp, action: Action<String>) -> Result<Value, String> {
|
||||
let registered = |id: &String| normalize(id) == Some(id.as_str());
|
||||
let known = match &action {
|
||||
Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => {
|
||||
registered(panel)
|
||||
&& registered(anchor)
|
||||
&& match placement {
|
||||
Placement::Tab { before: Some(id) } => registered(id),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered),
|
||||
Action::Float { panel, .. }
|
||||
| Action::Close { panel }
|
||||
| Action::Activate { panel }
|
||||
| Action::MoveFloating { panel, .. }
|
||||
| Action::SetStackOpen { panel, .. }
|
||||
| Action::ResizeStack { panel, .. } => registered(panel),
|
||||
Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH,
|
||||
};
|
||||
if !known {
|
||||
return Err("docking action contains an unknown panel or an excessive split path".into());
|
||||
}
|
||||
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
|
||||
return Err("saved panel layout is invalid; reset the workspace first".into());
|
||||
}
|
||||
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
|
||||
let mut icons = icon_origins(app);
|
||||
let explicit_dock = match &action {
|
||||
Action::Move { panel, anchor, .. } | Action::OpenAt { panel, anchor, .. }
|
||||
if (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(anchor) =>
|
||||
{
|
||||
Some(panel.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let returning = match &action {
|
||||
Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)),
|
||||
Action::Float { panel, .. } => {
|
||||
layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let fallback = match &action {
|
||||
Action::Float { panel, .. } => float_fallback(&layout, panel),
|
||||
_ => None,
|
||||
};
|
||||
let activated = match &action {
|
||||
Action::Activate { panel } => Some(panel.clone()),
|
||||
_ => None,
|
||||
};
|
||||
layout.apply(action).map_err(|error| error.to_string())?;
|
||||
restore_float_fallback(&mut layout, fallback)?;
|
||||
if let Some((panel, location)) = returning {
|
||||
app.ui.docking_hidden.insert(panel, location);
|
||||
}
|
||||
if let Some(panel) = explicit_dock {
|
||||
icons.remove(&panel);
|
||||
}
|
||||
app.ui.docking_icons = icons;
|
||||
app.ui.docking = Some(layout);
|
||||
if let Some(panel) = activated {
|
||||
for group in &mut app.ui.floating_panels {
|
||||
if group.panels.contains(&panel) {
|
||||
group.collapsed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
sync_legacy(app);
|
||||
app.ui.open_panel = None;
|
||||
Ok(json!({"layout": app.ui.docking}))
|
||||
}
|
||||
|
||||
fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result<Value, String> {
|
||||
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
|
||||
if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) {
|
||||
return Err("saved panel layout is invalid; reset the workspace first".into());
|
||||
}
|
||||
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
|
||||
if params.get("x").is_some() != params.get("y").is_some() {
|
||||
return Err("x and y must be provided together".into());
|
||||
}
|
||||
let whole_group = match params.get("group") {
|
||||
None => false,
|
||||
Some(Value::Bool(value)) => *value,
|
||||
Some(_) => return Err("group must be true or false".into()),
|
||||
};
|
||||
let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] };
|
||||
let mut hidden: std::collections::BTreeMap<_, _> =
|
||||
app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect();
|
||||
let mut icons = icon_origins(app);
|
||||
let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel);
|
||||
if icon_panel && !layout.contains(&panel.to_string()) {
|
||||
icons.insert(panel.to_string());
|
||||
}
|
||||
if operation == "activate" && icon_panel && !layout.contains(&panel.to_string()) {
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel) { None } else { Some(panel.into()) };
|
||||
return Ok(json!({"panel": panel, "open": app.ui.open_panel}));
|
||||
}
|
||||
if operation == "dock" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
|
||||
let mut candidate = layout.clone();
|
||||
let mut open = None;
|
||||
for member in &members {
|
||||
if candidate.contains(member) {
|
||||
candidate.apply(Action::Close { panel: member.clone() }).map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
// Remove the complete group before restoring any saved neighbor. Otherwise a
|
||||
// still-floating group member can accidentally receive the first restored tab.
|
||||
for member in &members {
|
||||
if icons.contains(member) {
|
||||
open = Some(member.clone());
|
||||
} else if let Some(location) = hidden.get(member).filter(|location| {
|
||||
location.floating.is_none()
|
||||
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: candidate.root.clone(), floating: Vec::new() }.contains(anchor))
|
||||
}) {
|
||||
if candidate.restore(member.clone(), location).is_err() {
|
||||
// A removed neighboring group uses the normal dock as its documented fallback.
|
||||
ensure(&mut candidate, member)?;
|
||||
}
|
||||
} else {
|
||||
ensure(&mut candidate, member)?;
|
||||
}
|
||||
}
|
||||
if candidate.contains(&panel.to_string()) {
|
||||
candidate.apply(Action::Activate { panel: panel.to_string() }).map_err(|error| error.to_string())?;
|
||||
} else if icons.contains(panel) {
|
||||
open = Some(panel.to_string());
|
||||
}
|
||||
if !valid(&candidate) {
|
||||
return Err("invalid restored panel group".into());
|
||||
}
|
||||
app.ui.docking = Some(candidate);
|
||||
app.ui.docking_icons = icons;
|
||||
for member in &members {
|
||||
hidden.remove(member);
|
||||
}
|
||||
app.ui.docking_hidden = hidden;
|
||||
sync_legacy(app);
|
||||
app.ui.open_panel = open;
|
||||
return Ok(json!({"panel": panel, "floating": false, "layout": app.ui.docking}));
|
||||
}
|
||||
let mut restored = false;
|
||||
if operation == "dock"
|
||||
&& params.get("anchor").or_else(|| params.get("onto")).is_none()
|
||||
&& let Some(location) = hidden.get(panel).filter(|location| {
|
||||
location.floating.is_none()
|
||||
&& location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor))
|
||||
})
|
||||
{
|
||||
let mut candidate = layout.clone();
|
||||
if candidate.contains(&panel.to_string()) {
|
||||
candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if candidate.restore(panel.into(), location).is_ok() {
|
||||
layout = candidate;
|
||||
restored = true;
|
||||
hidden.remove(panel);
|
||||
}
|
||||
}
|
||||
match operation {
|
||||
"dock" if restored => {}
|
||||
"float" => {
|
||||
let old = layout
|
||||
.floating
|
||||
.iter()
|
||||
.find(|group| group.panels.iter().any(|id| id == panel) && group.panels.len() == members.len())
|
||||
.map(|group| group.rect)
|
||||
.unwrap_or([400.0 + 24.0 * layout.floating.len().min(12) as f32, 140.0 + 24.0 * layout.floating.len().min(12) as f32, 300.0, 400.0]);
|
||||
let rect =
|
||||
[number(params, "x", old[0])?, number(params, "y", old[1])?, number(params, "width", old[2])?, number(params, "height", old[3])?];
|
||||
let fallback = float_fallback(&layout, panel).or_else(|| first_root(&layout, panel));
|
||||
for member in &members {
|
||||
if let Ok(location) = layout.location(member)
|
||||
&& location.floating.is_none()
|
||||
{
|
||||
hidden.insert(member.clone(), location);
|
||||
}
|
||||
}
|
||||
ensure(&mut layout, panel)?;
|
||||
if let Ok(location) = layout.location(&panel.to_string())
|
||||
&& location.floating.is_none()
|
||||
{
|
||||
hidden.insert(panel.into(), location);
|
||||
}
|
||||
if let Some(onto) = params.get("onto") {
|
||||
let onto = onto.as_str().and_then(normalize).ok_or("onto must name a floating panel")?;
|
||||
if members.iter().any(|id| id == onto)
|
||||
|| layout.floating.iter().any(|group| group.panels.iter().any(|id| id == panel) && group.panels.iter().any(|id| id == onto))
|
||||
|| !layout.floating.iter().any(|group| group.panels.iter().any(|id| id == onto))
|
||||
{
|
||||
return Err("onto must name a panel floating in another group".into());
|
||||
}
|
||||
for member in &members {
|
||||
layout
|
||||
.apply(Action::Move { panel: member.clone(), anchor: onto.into(), placement: Placement::Tab { before: None } })
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
} else {
|
||||
let lead = members.first().ok_or("panel group is empty")?;
|
||||
layout.apply(Action::Float { panel: lead.clone(), rect }).map_err(|e| e.to_string())?;
|
||||
for member in members.iter().filter(|id| *id != lead) {
|
||||
layout
|
||||
.apply(Action::Move { panel: member.clone(), anchor: lead.clone(), placement: Placement::Tab { before: None } })
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
|
||||
restore_float_fallback(&mut layout, fallback)?;
|
||||
}
|
||||
"move" | "dock" => {
|
||||
if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() {
|
||||
return Err("move requires a destination panel".into());
|
||||
}
|
||||
let anchor = match params.get("anchor").or_else(|| params.get("onto")) {
|
||||
Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()),
|
||||
None => first_root(&layout, panel),
|
||||
};
|
||||
let placement = placement(params)?;
|
||||
if let Some(anchor) = anchor {
|
||||
ensure(&mut layout, panel)?;
|
||||
layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?;
|
||||
} else if operation == "dock" {
|
||||
if layout.contains(&panel.to_string()) {
|
||||
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
return Err("move requires an existing destination panel".into());
|
||||
}
|
||||
}
|
||||
"close" => {
|
||||
hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?);
|
||||
layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
_ => {
|
||||
if !layout.contains(&panel.to_string())
|
||||
&& let Some(location) = hidden.get(panel)
|
||||
{
|
||||
// A missing neighbor is allowed: the normal visible dock becomes the fallback.
|
||||
let _ = layout.restore(panel.into(), location);
|
||||
}
|
||||
ensure(&mut layout, panel)?;
|
||||
hidden.remove(panel);
|
||||
}
|
||||
}
|
||||
if operation == "dock" && whole_group {
|
||||
for member in members.iter().filter(|id| id.as_str() != panel) {
|
||||
layout
|
||||
.apply(Action::Move { panel: member.clone(), anchor: panel.into(), placement: Placement::Tab { before: None } })
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let group = layout.floating.iter().find(|group| group.panels.iter().any(|id| id == panel));
|
||||
let result = json!({"panel": panel, "floating": group.is_some(), "group": group.map(|g| &g.panels), "pos": group.map(|g| [g.rect[0], g.rect[1]]), "layout": layout});
|
||||
if matches!(operation, "move" | "dock") && (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(&panel.to_string()) {
|
||||
icons.remove(panel);
|
||||
}
|
||||
app.ui.docking = Some(layout);
|
||||
app.ui.docking_hidden = hidden;
|
||||
app.ui.docking_icons = icons;
|
||||
if operation == "activate" {
|
||||
for group in &mut app.ui.floating_panels {
|
||||
if group.panels.iter().any(|id| id == panel) {
|
||||
group.collapsed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
sync_legacy(app);
|
||||
app.ui.open_panel = None;
|
||||
app.ui.dock = true;
|
||||
if matches!(operation, "activate" | "move" | "dock") {
|
||||
app.ui.dock_collapsed = false;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
struct PanelContent<'a> {
|
||||
app: &'a mut VectorcraftApp,
|
||||
commands: Vec<(&'static str, Value)>,
|
||||
}
|
||||
|
||||
impl craft_ui::docking::DockContent<String> for PanelContent<'_> {
|
||||
fn body(&mut self, ui: &mut egui::Ui, panel: &String) {
|
||||
let width = ui.available_width();
|
||||
let height = (ui.available_height() - 20.0).max(0.0);
|
||||
if crate::state::DockTab::from_id(panel).is_some() {
|
||||
crate::dock::panel_body(self.app, ui, panel, width, height);
|
||||
} else {
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt(("shared-panel-body", panel))
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| crate::dock::panel_body(self.app, ui, panel, width, height));
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_menu(&mut self, ui: &mut egui::Ui, panel: &String) {
|
||||
crate::panels::panel_menu_items(self.app, ui, panel);
|
||||
}
|
||||
|
||||
fn group_header_width(&self, _panels: &[String], _active: &String, floating: bool) -> f32 {
|
||||
if floating { 48.0 } else { 80.0 }
|
||||
}
|
||||
|
||||
fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, floating: bool) {
|
||||
let rect = ui.max_rect();
|
||||
let menu = egui::Rect::from_center_size(rect.right_center() - egui::vec2(12.0, 0.0), egui::vec2(16.0, 16.0));
|
||||
crate::panels::panel_menu(self.app, ui, active, menu);
|
||||
let grip = egui::Rect::from_min_max(rect.min, egui::pos2(menu.left() - 4.0, rect.bottom()));
|
||||
if floating {
|
||||
let response = ui.put(grip, egui::Button::new("×")).on_hover_text(tl!("Dock Panel"));
|
||||
if response.clicked() {
|
||||
self.commands.push(("window.panel.dock", json!({"panel": active, "group": true})));
|
||||
}
|
||||
} else {
|
||||
let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::drag());
|
||||
if response.drag_started()
|
||||
&& let Some(pos) = response.interact_pointer_pos()
|
||||
{
|
||||
ui.ctx().data_mut(|data| {
|
||||
data.insert_temp(
|
||||
egui::Id::new("vectorcraft-root-group-drag"),
|
||||
RootGroupDrag {
|
||||
panel: active.clone(),
|
||||
layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)),
|
||||
hidden: self.app.ui.docking_hidden.clone(),
|
||||
generation: self.app.ui.docking_generation,
|
||||
},
|
||||
)
|
||||
});
|
||||
self.commands.push(("window.panel.float", json!({"panel": active, "group": true, "x": pos.x, "y": pos.y})));
|
||||
}
|
||||
response.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
|
||||
let root_key = egui::Id::new("vectorcraft-root-group-drag");
|
||||
if let Some(drag) = ui.ctx().data(|data| data.get_temp::<RootGroupDrag>(root_key)) {
|
||||
if drag.generation != app.ui.docking_generation {
|
||||
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
|
||||
} else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.docking = Some(drag.layout);
|
||||
app.ui.docking_hidden = drag.hidden;
|
||||
sync_legacy(app);
|
||||
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
|
||||
} else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) {
|
||||
if ui.input(|input| input.pointer.primary_down()) {
|
||||
if let Err(error) = app.run("window.panel.float", json!({"panel":drag.panel, "group":true, "x":pos.x, "y":pos.y})) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
if let Some(panel) = normalize(&drag.panel)
|
||||
&& let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
|
||||
{
|
||||
crate::floating::preview(ui.ctx(), target, rect);
|
||||
}
|
||||
} else {
|
||||
if let Some(panel) = normalize(&drag.panel)
|
||||
&& let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
|
||||
{
|
||||
let (id, params) = match target {
|
||||
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})),
|
||||
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})),
|
||||
crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})),
|
||||
crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})),
|
||||
crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})),
|
||||
};
|
||||
if let Err(error) = app.run(id, params) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
}
|
||||
ui.ctx().data_mut(|data| data.remove::<RootGroupDrag>(root_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
let area = egui::Id::new("vectorcraft-panel-docking");
|
||||
let changed_workspace = ui.ctx().data_mut(|data| {
|
||||
let previous = data.get_temp::<u64>(area.with("generation"));
|
||||
data.insert_temp(area.with("generation"), app.ui.docking_generation);
|
||||
previous.is_some_and(|generation| generation != app.ui.docking_generation)
|
||||
});
|
||||
if changed_workspace {
|
||||
craft_ui::docking::cancel_drag::<String>(ui.ctx(), area);
|
||||
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(area.with("legacy-origin")));
|
||||
}
|
||||
|
||||
let cancelled =
|
||||
ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape) || (!input.pointer.primary_down() && !input.pointer.any_released()));
|
||||
if cancelled && cancel_legacy_drag(app, ui.ctx()) && app.ui.docking.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(layout) = app.ui.docking.as_ref() else { return false };
|
||||
if !valid(layout) {
|
||||
app.ui.docking = None;
|
||||
app.ui.status = "Invalid panel layout; restored the default dock".into();
|
||||
return false;
|
||||
}
|
||||
let layout = layout.clone();
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let collapsed = app.ui.dock_collapsed;
|
||||
let mut display = if collapsed { Layout { root: None, floating: layout.floating.clone() } } else { layout.clone() };
|
||||
display
|
||||
.floating
|
||||
.retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id))));
|
||||
let mut draw = |ui: &mut egui::Ui| {
|
||||
if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) {
|
||||
let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed}));
|
||||
}
|
||||
if collapsed {
|
||||
let root = Layout { root: layout.root.clone(), floating: Vec::new() };
|
||||
for panel in root.panels() {
|
||||
let info = crate::state::DockTab::from_id(panel)
|
||||
.map(|tab| tab.info())
|
||||
.or_else(|| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == panel).copied());
|
||||
if let Some((_, label, icon)) = info
|
||||
&& crate::widgets::icon_button(ui, icon, tl!(label), app.ui.open_panel.as_ref() == Some(panel), 30.0).clicked()
|
||||
{
|
||||
app.ui.open_panel = if app.ui.open_panel.as_ref() == Some(panel) { None } else { Some(panel.clone()) };
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut style = craft_ui::docking::DockStyle::from_ui(ui);
|
||||
style.tab_height = 33.0;
|
||||
style.background = t.panel;
|
||||
style.tab_background = t.tab_strip;
|
||||
style.active_background = t.panel;
|
||||
style.text = t.text_strong;
|
||||
style.inactive_text = t.text_dim;
|
||||
style.border = egui::Stroke::new(1.0, t.border);
|
||||
style.accent = t.accent;
|
||||
style.float_label = tl!("Float Panel").to_string();
|
||||
style.close_label = tl!("Close Panel").to_string();
|
||||
style.move_label = tl!("Group with").to_string();
|
||||
style.panels_label = tl!("Panels").to_string();
|
||||
style.resize_label = tl!("Resize panels").to_string();
|
||||
style.resize_window_label = tl!("Resize panel window").to_string();
|
||||
let mut content = PanelContent { app, commands: Vec::new() };
|
||||
let output = craft_ui::docking::DockArea::new(egui::Id::new("vectorcraft-panel-docking")).show_customized(
|
||||
ui,
|
||||
&display,
|
||||
&style,
|
||||
|id| crate::state::all_panels().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()),
|
||||
|_| craft_ui::docking::Permissions::default(),
|
||||
|_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() },
|
||||
&mut content,
|
||||
);
|
||||
for (id, params) in content.commands {
|
||||
if let Err(error) = app.run(id, params) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
}
|
||||
output
|
||||
};
|
||||
let output = if layout.root.is_some() {
|
||||
let panel = egui::Panel::right("shared-panel-dock")
|
||||
.default_size(310.0)
|
||||
.size_range(220.0..=720.0)
|
||||
.resizable(true)
|
||||
.frame(egui::Frame::NONE.fill(t.panel));
|
||||
let panel = if collapsed { panel.exact_size(38.0).resizable(false) } else { panel };
|
||||
let shown = panel.show(ui, &mut draw);
|
||||
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::dock_rect_id(), shown.response.rect));
|
||||
shown.inner
|
||||
} else {
|
||||
draw(ui)
|
||||
};
|
||||
if let Some(panel) = output.group_drag.as_deref().and_then(normalize)
|
||||
&& let Some(pos) = ui.input(|input| input.pointer.interact_pos())
|
||||
&& let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
|
||||
{
|
||||
crate::floating::preview(ui.ctx(), target, rect);
|
||||
}
|
||||
if let Some(error) = output.error {
|
||||
app.ui.status = error.to_string();
|
||||
}
|
||||
// Tab and icon tears finish as model actions; title moves finish as group_drop.
|
||||
// Native sets are absent from the shared render tree, so their pointer target must win
|
||||
// over a float proposal or a root target drawn behind the native window.
|
||||
let tab_drop = ui.input(|input| input.pointer.primary_released().then(|| input.pointer.interact_pos()).flatten()).and_then(|pos| {
|
||||
output.actions.iter().find_map(|action| {
|
||||
let panel = match action {
|
||||
Action::Float { panel, .. } | Action::Move { panel, .. } => normalize(panel)?,
|
||||
_ => return None,
|
||||
};
|
||||
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos)
|
||||
.filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock))
|
||||
.map(|(target, _)| (panel, target))
|
||||
})
|
||||
});
|
||||
if ui.input(|input| input.pointer.any_released()) {
|
||||
let native_drop = output.group_drop.as_ref().is_some_and(|(panel, pos)| {
|
||||
normalize(panel).and_then(|id| crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(id), *pos)).is_some()
|
||||
});
|
||||
if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. }))
|
||||
&& !native_drop
|
||||
&& tab_drop.is_none()
|
||||
&& cancel_legacy_drag(app, ui.ctx())
|
||||
{
|
||||
return app.ui.docking.is_some();
|
||||
}
|
||||
ui.ctx().data_mut(|data| data.remove::<LegacyDrag>(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin")));
|
||||
}
|
||||
for action in output.actions {
|
||||
if tab_drop
|
||||
.is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = app.run("window.panel.layout", json!({"action": action})) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
}
|
||||
let group_drop = output.group_drop.and_then(|(panel, pos)| {
|
||||
let panel = normalize(&panel)?;
|
||||
crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true))
|
||||
});
|
||||
if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) {
|
||||
let (command, params) = match target {
|
||||
crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})),
|
||||
crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})),
|
||||
crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})),
|
||||
crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})),
|
||||
crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})),
|
||||
};
|
||||
// Shared and native windows have already chosen their render ownership for this
|
||||
// frame. Switch representations only after both renderers finish, so a panel's
|
||||
// widgets never appear in two layers in the same frame.
|
||||
ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("vectorcraft-native-panel-drop"), (command, params)));
|
||||
}
|
||||
for group in &display.floating {
|
||||
if let Some(first) = group.panels.first()
|
||||
&& let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(area.with(("floating", first))))
|
||||
{
|
||||
ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect));
|
||||
}
|
||||
}
|
||||
icon_rail(app, ui);
|
||||
crate::floating::docked_columns(app, ui);
|
||||
true
|
||||
}
|
||||
|
||||
fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let column = egui::Panel::right("shared-panel-icons")
|
||||
.exact_size(38.0)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.panel))
|
||||
.show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
for &(id, label, icon) in crate::state::ICON_PANEL_GROUPS
|
||||
.iter()
|
||||
.flat_map(|group| group.iter())
|
||||
.filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id))
|
||||
{
|
||||
if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) {
|
||||
continue;
|
||||
}
|
||||
let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0));
|
||||
let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag());
|
||||
let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id));
|
||||
if response.clicked() {
|
||||
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) };
|
||||
}
|
||||
legacy_tab(app, ui, id, &response);
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.rect;
|
||||
ui.ctx().data_mut(|data| {
|
||||
let dock = if app.ui.docking.as_ref().is_some_and(|layout| layout.root.is_some()) {
|
||||
data.get_temp::<egui::Rect>(crate::floating::dock_rect_id()).unwrap_or(column)
|
||||
} else {
|
||||
column
|
||||
};
|
||||
data.insert_temp(crate::floating::dock_rect_id(), dock.union(column));
|
||||
data.insert_temp(crate::floating::icons_rect_id(), column);
|
||||
data.insert_temp(egui::Id::new("dock-icon-column-left"), column.left());
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) {
|
||||
let Some(panel) = normalize(panel) else { return };
|
||||
response.context_menu(|ui| {
|
||||
if ui.button(tl!("Float Panel")).clicked() {
|
||||
if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) {
|
||||
app.ui.status = error;
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
ui.menu_button(tl!("Group with"), |ui| {
|
||||
for (anchor, label) in crate::state::all_panels() {
|
||||
if anchor != panel && ui.button(tl!(label)).clicked() {
|
||||
// A closed icon panel is made visible before becoming the destination.
|
||||
let result = app
|
||||
.run("window.panel.activate", json!({"panel": anchor}))
|
||||
.and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor})));
|
||||
if let Err(error) = result {
|
||||
app.ui.status = error;
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if response.drag_started()
|
||||
&& let Some(id) = normalize(panel)
|
||||
{
|
||||
let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app));
|
||||
let bounds = ui.max_rect();
|
||||
let source = egui::Rect::from_min_size(
|
||||
egui::pos2(bounds.left(), response.rect.top()),
|
||||
egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)),
|
||||
);
|
||||
if ensure(&mut layout, id).is_ok()
|
||||
&& craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("vectorcraft-panel-docking"), id.to_string(), source)
|
||||
{
|
||||
ui.ctx().data_mut(|data| {
|
||||
data.insert_temp(
|
||||
egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"),
|
||||
LegacyDrag {
|
||||
layout: app.ui.docking.clone(),
|
||||
open_panel: app.ui.open_panel.clone(),
|
||||
floating: app.ui.floating_panels.clone(),
|
||||
icons: app.ui.docking_icons.clone(),
|
||||
},
|
||||
)
|
||||
});
|
||||
if !app.ui.docking.as_ref().unwrap_or(&legacy(app)).contains(&id.to_string())
|
||||
&& crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id)
|
||||
{
|
||||
app.ui.docking_icons.insert(id.into());
|
||||
}
|
||||
app.ui.docking = Some(layout);
|
||||
app.ui.open_panel = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the legacy inspection schema useful; shared layout remains the rendering authority.
|
||||
pub(crate) fn decorated(group: &crate::state::FloatingPanels) -> bool {
|
||||
group.column.is_some() || group.docked.is_some() || group.collapsed
|
||||
}
|
||||
|
||||
/// Keep the native set metadata alongside the validated shared ownership tree. Return locations
|
||||
/// and root splits survive the mirror update; failed reconciliation restores the whole UI state.
|
||||
pub(crate) fn reconcile_native(app: &mut VectorcraftApp, before: crate::state::UiState) -> Result<(), String> {
|
||||
if before.docking.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let desired = app.ui.floating_panels.clone();
|
||||
let result = (|| {
|
||||
let prior = before.docking.as_ref().ok_or("missing panel layout")?;
|
||||
app.ui.docking = Some(prior.clone());
|
||||
for group in &prior.floating {
|
||||
for panel in &group.panels {
|
||||
if !desired.iter().any(|g| g.panels.contains(panel)) {
|
||||
change(app, "dock", &json!({"panel":panel}))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut layout = app.ui.docking.clone().ok_or("missing panel layout")?;
|
||||
for group in &desired {
|
||||
for panel in &group.panels {
|
||||
if (Layout { root: layout.root.clone(), floating: vec![] }).contains(panel) {
|
||||
if let Ok(location) = layout.location(panel) {
|
||||
app.ui.docking_hidden.insert(panel.clone(), location);
|
||||
}
|
||||
layout.apply(Action::Close { panel: panel.clone() }).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
layout.floating = desired
|
||||
.iter()
|
||||
.map(|group| {
|
||||
let height = prior.floating.iter().find(|g| g.panels.iter().any(|id| group.panels.contains(id))).map_or(400.0, |g| g.rect[3]);
|
||||
Floating {
|
||||
panels: group.panels.clone(),
|
||||
active: group.active,
|
||||
rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0), height],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if !valid(&layout) {
|
||||
return Err("invalid native panel set".into());
|
||||
}
|
||||
app.ui.docking = Some(layout);
|
||||
app.ui.floating_panels = desired;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
app.ui = before;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn native_change(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result<Value, String> {
|
||||
let before = app.ui.clone();
|
||||
let result = (|| {
|
||||
let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?;
|
||||
if crate::floating::group_of(&app.ui, panel).is_none() {
|
||||
let mut initial = params.clone();
|
||||
if let Some(object) = initial.as_object_mut() {
|
||||
for key in ["above", "below", "column", "collapsed", "onto"] {
|
||||
object.remove(key);
|
||||
}
|
||||
}
|
||||
change(app, "float", &initial)?;
|
||||
}
|
||||
let baseline = app.ui.clone();
|
||||
let value = crate::floating::command(app, id, params).ok_or("unsupported native docking operation")??;
|
||||
reconcile_native(app, baseline)?;
|
||||
Ok(value)
|
||||
})();
|
||||
if result.is_err() {
|
||||
app.ui = before;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn sync_legacy(app: &mut VectorcraftApp) {
|
||||
if let Some(layout) = &app.ui.docking {
|
||||
let old = app.ui.floating_panels.clone();
|
||||
let mut pending: Vec<_> = layout.root.iter().collect();
|
||||
while let Some(node) = pending.pop() {
|
||||
match node {
|
||||
Node::Tabs { panels, active } => {
|
||||
if let Some(tab) = panels.get(*active).and_then(|id| crate::state::DockTab::from_id(id)) {
|
||||
app.ui.dock_tab = tab;
|
||||
}
|
||||
}
|
||||
Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
app.ui.floating_panels = layout
|
||||
.floating
|
||||
.iter()
|
||||
.map(|group| crate::state::FloatingPanels {
|
||||
panels: group.panels.clone(),
|
||||
active: group.active,
|
||||
pos: [group.rect[0], group.rect[1]],
|
||||
width: Some(group.rect[2]),
|
||||
column: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.column),
|
||||
collapsed: old
|
||||
.iter()
|
||||
.find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first)))
|
||||
.is_some_and(|g| g.collapsed),
|
||||
docked: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.docked),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "panel_docking_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Commit a pointer drop after shared and native panel windows have finished rendering.
|
||||
pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if let Some((command, params)) =
|
||||
ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop")))
|
||||
&& let Err(error) = app.run(command, params)
|
||||
{
|
||||
app.ui.status = error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
use super::*;
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
|
||||
app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap();
|
||||
let before_float = app.ui.docking.clone().unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap();
|
||||
assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"]));
|
||||
app.run("window.panel.dock", json!({"panel":"properties"})).unwrap();
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
|
||||
app.run("window.panel.close", json!({"panel":"properties"})).unwrap();
|
||||
assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into()));
|
||||
app.run("window.panel.activate", json!({"panel":"properties"})).unwrap();
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float);
|
||||
let layout = app.ui.docking.as_ref().unwrap();
|
||||
assert!(valid(layout));
|
||||
let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect();
|
||||
assert_eq!(unique.len(), layout.panels().len());
|
||||
let saved = serde_json::to_value(&app.ui).unwrap();
|
||||
let restored: crate::state::UiState = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(restored.docking, app.ui.docking);
|
||||
assert_eq!(restored.docking_hidden, app.ui.docking_hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() {
|
||||
let mut app = app();
|
||||
for (id, params) in [
|
||||
("window.panel.move", json!({"panel":"layers","anchor":"unknown"})),
|
||||
("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})),
|
||||
("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})),
|
||||
("window.panel.float", json!({"panel":"layers","width":-10})),
|
||||
("window.panel.float", json!({"panel":"unknown"})),
|
||||
("window.panel.float", json!({"panel":"layers","x":"invalid"})),
|
||||
] {
|
||||
let old = app.ui.docking.clone();
|
||||
let hidden = app.ui.docking_hidden.clone();
|
||||
assert!(app.run(id, params).is_err(), "{id}");
|
||||
assert_eq!(app.ui.docking, old);
|
||||
assert_eq!(app.ui.docking_hidden, hidden);
|
||||
}
|
||||
}
|
||||
|
||||
fn harness(app: VectorcraftApp, size: egui::Vec2) -> Harness<'static, VectorcraftApp> {
|
||||
let mut ready = false;
|
||||
let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !ready {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
egui_extras::install_image_loaders(ui.ctx());
|
||||
crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
crate::floating::track(app, ui.ctx());
|
||||
crate::dock::show(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |_| {});
|
||||
crate::floating::show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(4);
|
||||
harness
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'static, VectorcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for step in 1..=8 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() {
|
||||
let mut h = harness(app(), vec2(1280.0, 800.0));
|
||||
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
|
||||
drag(&mut h, source, egui::pos2(160.0, 140.0));
|
||||
let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking");
|
||||
assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}");
|
||||
let source = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center();
|
||||
let destination = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
|
||||
drag(&mut h, source, destination);
|
||||
let layout = h.state().ui.docking.as_ref().unwrap();
|
||||
assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into())));
|
||||
assert!(valid(layout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() {
|
||||
let mut app = app();
|
||||
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap();
|
||||
app.run("window.panel.close", json!({"panel":"swatches"})).unwrap();
|
||||
let layout = app.ui.docking.clone();
|
||||
let hidden = app.ui.docking_hidden.clone();
|
||||
app.run("window.workspace.new", json!({"name":"Custom docking"})).unwrap();
|
||||
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
|
||||
assert!(app.ui.docking.is_none());
|
||||
app.run("window.workspace", json!({"name":"Custom docking"})).unwrap();
|
||||
assert_eq!(app.ui.docking, layout);
|
||||
assert_eq!(app.ui.docking_hidden, hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_float_group_aliases_use_atomic_shared_groups_and_preserve_tools() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"layers","group":true,"x":30,"y":50})).unwrap();
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 3);
|
||||
app.run("window.panel.float", json!({"panel":"swatches","onto":"layers"})).unwrap();
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 4);
|
||||
let before = app.ui.docking.clone();
|
||||
assert!(app.run("window.panel.float", json!({"panel":"properties","onto":"missing","group":true})).is_err());
|
||||
assert_eq!(app.ui.docking, before);
|
||||
app.run("window.panel.float", json!({"panel":"tools","x":70,"y":90})).unwrap();
|
||||
assert_eq!(app.ui.toolbar_pos, Some([70.0, 90.0]));
|
||||
assert_eq!(app.ui.docking, before);
|
||||
app.run("window.panel.dock", json!({"panel":"layers","group":true})).unwrap();
|
||||
assert!(app.ui.docking.as_ref().unwrap().floating.is_empty());
|
||||
}
|
||||
|
||||
/// Explicit offscreen evidence; this creates no native windows.
|
||||
#[test]
|
||||
fn capture_custom_panel_docking_visual_fixtures() {
|
||||
let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return };
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for theme in crate::theme::Brightness::ALL {
|
||||
for width in [800.0, 1280.0] {
|
||||
for scale in [1.0, 1.5, 2.0] {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap();
|
||||
let mut ready = false;
|
||||
let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !ready {
|
||||
egui_extras::install_image_loaders(ui.ctx());
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
crate::theme::apply(ui.ctx(), theme);
|
||||
ready = true;
|
||||
return;
|
||||
}
|
||||
crate::dock::show(app, ui);
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
ui.label("Custom panel workspace");
|
||||
});
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
harness.run_steps(5);
|
||||
assert!(valid(harness.state().ui.docking.as_ref().unwrap()));
|
||||
harness.render().unwrap().save(directory.join(format!("vectorcraft-shared-docking-{}-{width}-{scale}x.png", theme.id()))).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() {
|
||||
let mut h = harness(app(), vec2(1280.0, 800.0));
|
||||
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
|
||||
h.event(Event::PointerMoved(source));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.docking.is_some());
|
||||
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
assert!(h.state().ui.docking.is_none());
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.docking.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap();
|
||||
assert!(
|
||||
matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001)
|
||||
);
|
||||
app.run("window.panel.float", json!({"panel":"properties"})).unwrap();
|
||||
app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap();
|
||||
assert_eq!(
|
||||
app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect,
|
||||
[44.0, 66.0, 360.0, 450.0]
|
||||
);
|
||||
app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}}))
|
||||
.unwrap();
|
||||
assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]);
|
||||
let before = app.ui.docking.clone();
|
||||
let hidden = app.ui.docking_hidden.clone();
|
||||
for action in [
|
||||
json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}),
|
||||
json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}),
|
||||
json!({"Open":{"panel":"unknown","anchor":null}}),
|
||||
json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}),
|
||||
] {
|
||||
assert!(app.run("window.panel.layout", json!({"action":action})).is_err());
|
||||
assert_eq!(app.ui.docking, before);
|
||||
assert_eq!(app.ui.docking_hidden, hidden);
|
||||
}
|
||||
app.ui.docking = Some(Layout {
|
||||
root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }),
|
||||
floating: Vec::new(),
|
||||
});
|
||||
app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap();
|
||||
app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap();
|
||||
let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") };
|
||||
assert!(!entries[0].open);
|
||||
assert_eq!(entries[0].height, Some(150.0));
|
||||
}
|
||||
|
||||
fn group_members_for_test(layout: &Layout<String>, panel: &str) -> Vec<String> {
|
||||
let mut pending: Vec<_> = layout.root.iter().collect();
|
||||
while let Some(node) = pending.pop() {
|
||||
match node {
|
||||
Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(),
|
||||
Node::Split { first, second, .. } => pending.extend([first.as_ref(), second.as_ref()]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({"width":480,"height":360})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x":80,"y":70,"width":250,"height":160})).unwrap()["id"].clone();
|
||||
app.run("select.set", json!({"ids":[id]})).unwrap();
|
||||
app.run("paint.setFill", json!({"color":"#5297bb"})).unwrap();
|
||||
app.run("stroke.set", json!({"weight":8})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap();
|
||||
let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from);
|
||||
let builder = Harness::builder().with_size(vec2(1280.0, 800.0));
|
||||
let builder = if directory.is_some() { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_ui_state(
|
||||
|ui, app: &mut VectorcraftApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.input_mut().max_texture_side = Some(8192);
|
||||
h.run_steps(5);
|
||||
let mut pending: Vec<_> = h.output().shapes.iter().map(|shape| &shape.shape).collect();
|
||||
let mut checkbox = None;
|
||||
while let Some(shape) = pending.pop() {
|
||||
match shape {
|
||||
egui::Shape::Text(text) if text.galley.text() == "Dashed Line" => {
|
||||
checkbox = Some(text.visual_bounding_rect().center());
|
||||
break;
|
||||
}
|
||||
egui::Shape::Vec(shapes) => pending.extend(shapes),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let at = checkbox.expect("painted Dashed Line checkbox");
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
let dash = crate::panels::current_stroke(h.state()).unwrap().dash.unwrap();
|
||||
assert_eq!(dash.pattern, [12.0, 12.0]);
|
||||
if let Some(directory) = &directory {
|
||||
std::fs::create_dir_all(directory).unwrap();
|
||||
h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-floating.png")).unwrap();
|
||||
}
|
||||
let from = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center();
|
||||
let to = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center();
|
||||
drag(&mut h, from, to);
|
||||
assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty());
|
||||
assert_eq!(crate::panels::current_stroke(h.state()).unwrap().dash.unwrap(), dash);
|
||||
if let Some(directory) = &directory {
|
||||
h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-redocked.png")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_workspace_during_a_drag_cancels_the_stale_gesture() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
let destination = app.ui.docking.clone();
|
||||
app.run("window.workspace.new", json!({"name":"Destination"})).unwrap();
|
||||
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 800.0));
|
||||
let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
|
||||
h.event(Event::PointerMoved(source));
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
h.event(Event::PointerMoved(source - vec2(90.0, 0.0)));
|
||||
h.run_steps(2);
|
||||
h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap();
|
||||
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().ui.docking, destination);
|
||||
h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.docking, destination);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn floating_icon_groups_redock_to_the_rail_and_keep_saved_origins() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"color"})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"swatches", "onto":"color"})).unwrap();
|
||||
assert_eq!(app.ui.docking_icons.len(), 2);
|
||||
let workspace = crate::workspaces::capture(&app.ui, "Icon origins");
|
||||
let saved = serde_json::to_vec(&workspace).unwrap();
|
||||
let restored: crate::workspaces::Workspace = serde_json::from_slice(&saved).unwrap();
|
||||
crate::workspaces::apply(&mut app.ui, &restored);
|
||||
app.run("window.panel.dock", json!({"panel":"color", "group":true})).unwrap();
|
||||
let layout = app.ui.docking.as_ref().unwrap();
|
||||
assert!(!layout.contains(&"color".into()));
|
||||
assert!(!layout.contains(&"swatches".into()));
|
||||
assert!(app.ui.floating_panels.is_empty());
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("color"), "the requested icon pops out after redocking");
|
||||
app.run("window.panel", json!({"panel":"color"})).unwrap();
|
||||
assert!(app.ui.open_panel.is_none(), "the same icon puts it away");
|
||||
app.run("window.panel", json!({"panel":"color"})).unwrap();
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some("color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_dock_group_origins_survive_float_and_return() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"layers", "group":true})).unwrap();
|
||||
assert_eq!(app.ui.floating_panels[0].panels.len(), 3);
|
||||
assert!(app.ui.docking_hidden.contains_key("properties"));
|
||||
assert!(app.ui.docking_hidden.contains_key("layers"));
|
||||
assert!(app.ui.docking_hidden.contains_key("libraries"));
|
||||
app.run("window.panel.dock", json!({"panel":"layers", "group":true})).unwrap();
|
||||
let layout = app.ui.docking.as_ref().unwrap();
|
||||
assert!(layout.floating.is_empty());
|
||||
assert_eq!(layout.panels().into_iter().map(String::as_str).collect::<Vec<_>>(), ["properties", "layers", "libraries"]);
|
||||
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
|
||||
assert!(valid(layout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_specific_menu_executes_after_first_float_regroup_and_reload() {
|
||||
let mut app = app();
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":100})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 900.0));
|
||||
for stage in 0..3 {
|
||||
if stage == 1 {
|
||||
h.state_mut().run("window.panel.float", json!({"panel":"color","x":60,"y":120})).unwrap();
|
||||
h.state_mut().run("window.panel.float", json!({"panel":"swatches","onto":"color"})).unwrap();
|
||||
} else if stage == 2 {
|
||||
let saved = serde_json::to_vec(&h.state().ui).unwrap();
|
||||
h.state_mut().ui = serde_json::from_slice(&saved).unwrap();
|
||||
}
|
||||
h.run_steps(4);
|
||||
let at = h
|
||||
.query_all_by_label("Panel menu")
|
||||
.find(|node| node.rect().center().x < 800.0)
|
||||
.expect("floating panel menu has an accessible button")
|
||||
.rect()
|
||||
.center();
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
let action = h.query_all_by_label(" New Swatch…").next().expect("app-specific menu action remains reachable").rect().center();
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos: action, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(h.state().ui.dialog.as_ref().map(|dialog| dialog.kind.as_str()), Some("newSwatch"), "stage {stage}: actual panel action executes");
|
||||
h.state_mut().ui.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iconic_origin_tracks_explicit_custom_dock_and_legacy_saved_floats() {
|
||||
let mut custom = app();
|
||||
custom.run("window.panel.move", json!({"panel":"color","anchor":"layers"})).unwrap();
|
||||
let before = custom.ui.docking.clone().unwrap();
|
||||
custom.run("window.panel.float", json!({"panel":"color"})).unwrap();
|
||||
custom.run("window.panel.dock", json!({"panel":"color"})).unwrap();
|
||||
assert_eq!(custom.ui.docking.as_ref(), Some(&before), "explicit custom tabs are the saved destination");
|
||||
assert!(custom.ui.open_panel.is_none(), "a docked custom tab is not an icon popout");
|
||||
|
||||
let mut legacy = app();
|
||||
crate::floating::float(&mut legacy.ui, &["color"], "color", egui::pos2(120.0, 160.0));
|
||||
let saved = serde_json::to_vec(&legacy.ui).unwrap();
|
||||
legacy.ui = serde_json::from_slice(&saved).unwrap();
|
||||
assert!(legacy.ui.docking.is_none());
|
||||
legacy.run("window.panel.dock", json!({"panel":"color"})).unwrap();
|
||||
assert!(!legacy.ui.docking.as_ref().unwrap().contains(&"color".into()), "legacy icon floats return to rail");
|
||||
assert_eq!(legacy.ui.open_panel.as_deref(), Some("color"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_sets_columns_and_collapse_preserve_shared_splits_and_saved_origins() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
let original = app.ui.docking.clone().unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"color","below":"stroke","collapsed":true})).unwrap();
|
||||
let root = app.ui.docking.as_ref().unwrap().root.clone();
|
||||
assert_eq!(root, original.root);
|
||||
let column = app.ui.floating_panels.first().unwrap().column;
|
||||
assert!(column.is_some());
|
||||
assert_eq!(app.ui.floating_panels.len(), 2);
|
||||
assert!(app.ui.floating_panels.iter().all(|g| g.column == column));
|
||||
app.run("window.panel.dock", json!({"panel":"stroke","group":true,"column":true})).unwrap();
|
||||
assert!(app.ui.floating_panels.iter().all(|g| g.docked == Some(1)));
|
||||
app.run("window.workspace.new", json!({"name":"Mixed shared native"})).unwrap();
|
||||
let saved = app.ui.floating_panels.clone();
|
||||
app.run("window.workspace", json!({"name":"Essentials"})).unwrap();
|
||||
app.run("window.workspace", json!({"name":"Mixed shared native"})).unwrap();
|
||||
assert_eq!(app.ui.floating_panels, saved);
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap().root, root);
|
||||
app.run("window.panel.float", json!({"panel":"stroke","group":true})).unwrap();
|
||||
assert!(app.ui.floating_panels.iter().all(|g| g.docked.is_none()));
|
||||
let before = serde_json::to_value(&app.ui).unwrap();
|
||||
assert!(app.run("window.panel.float", json!({"panel":"stroke","below":"unknown"})).is_err());
|
||||
// Error reporting may change status, but ownership, metadata and root remain atomic.
|
||||
let mut after = serde_json::to_value(&app.ui).unwrap();
|
||||
let mut before = before;
|
||||
before.as_object_mut().unwrap().remove("status");
|
||||
after.as_object_mut().unwrap().remove("status");
|
||||
assert_eq!(before, after);
|
||||
app.run("window.panel.dock", json!({"panel":"stroke","group":true})).unwrap();
|
||||
app.run("window.panel.dock", json!({"panel":"color","group":true})).unwrap();
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap().root, original.root);
|
||||
assert!(app.ui.floating_panels.is_empty());
|
||||
assert!(valid(app.ui.docking.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_set_close_pointer_restores_shared_split_ownership() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
let root = app.ui.docking.as_ref().unwrap().root.clone();
|
||||
app.run("window.panel.float", json!({"panel":"stroke","x":70,"y":90})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 900.0));
|
||||
if let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR") {
|
||||
let directory = std::path::PathBuf::from(directory);
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
h.render().unwrap().save(directory.join("vectorcraft-native-set-shared-split.png")).unwrap();
|
||||
}
|
||||
let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap();
|
||||
let at = egui::pos2(area.right() - 10.0, area.top() + 1.0 + crate::floating::TITLE / 2.0);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert!(h.state().ui.floating_panels.is_empty());
|
||||
assert_eq!(h.state().ui.docking.as_ref().unwrap().root, root);
|
||||
assert!(valid(h.state().ui.docking.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_native_tab_redock_retains_the_remaining_float_and_return_origin() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap();
|
||||
let root = app.ui.docking.as_ref().unwrap().root.clone();
|
||||
app.run("window.panel.float", json!({"panel":"swatches","x":80,"y":90})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"color","onto":"swatches","collapsed":true})).unwrap();
|
||||
assert!(
|
||||
matches!(app.ui.docking.as_ref().unwrap().root.as_ref(), Some(Node::Tabs { panels, active }) if panels.get(*active).is_some_and(|panel| panel == "properties")),
|
||||
"floating Color intentionally activates the first root fallback, Properties"
|
||||
);
|
||||
let mut expected = Layout { root, floating: vec![] };
|
||||
expected.apply(Action::Activate { panel: "properties".to_string() }).unwrap();
|
||||
app.run("window.panel.dock", json!({"panel":"swatches"})).unwrap();
|
||||
assert_eq!(app.ui.docking.as_ref().unwrap().root, expected.root);
|
||||
assert_eq!(app.ui.floating_panels.len(), 1);
|
||||
assert_eq!(app.ui.floating_panels[0].panels, ["color"]);
|
||||
assert!(valid(app.ui.docking.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_shared_activation_expands_a_native_collapsed_owner() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"layers","collapsed":true})).unwrap();
|
||||
assert!(app.ui.floating_panels[0].collapsed);
|
||||
app.run("window.panel.layout", json!({"action":Action::Activate { panel:"layers".to_string() }})).unwrap();
|
||||
assert!(!app.ui.floating_panels[0].collapsed);
|
||||
assert_eq!(app.ui.floating_panels[0].panels, ["layers"]);
|
||||
assert!(valid(app.ui.docking.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_restores_native_set_position_and_metadata_after_several_frames() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"stroke","x":80,"y":100})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
|
||||
let mut h = harness(app, vec2(1280.0, 900.0));
|
||||
let original = h.state().ui.floating_panels.clone();
|
||||
let layout = h.state().ui.docking.clone();
|
||||
let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap();
|
||||
let from = area.left_top() + vec2(30.0, 1.0 + crate::floating::TITLE / 2.0);
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for step in 1..=8 {
|
||||
h.event(Event::PointerMoved(from + vec2(step as f32 * 12.0, step as f32 * 9.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_ne!(h.state().ui.floating_panels, original, "actual title gesture moved the set");
|
||||
h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.floating_panels, original);
|
||||
assert_eq!(h.state().ui.docking, layout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_legacy_icon_tear_can_join_a_native_set() {
|
||||
let mut app = app();
|
||||
crate::floating::float(&mut app.ui, &["stroke"], "stroke", egui::pos2(100.0, 100.0));
|
||||
crate::floating::float(&mut app.ui, &["color"], "color", egui::pos2(100.0, 100.0));
|
||||
crate::floating::attach(&mut app.ui, &[1], "stroke", true);
|
||||
assert!(app.ui.docking.is_none());
|
||||
let mut h = harness(app, vec2(1280.0, 900.0));
|
||||
let source = h.query_all_by_label("Swatches").find(|node| node.rect().height() <= 45.0).unwrap().rect().center();
|
||||
let color = h.ctx.data(|data| data.get_temp::<egui::Rect>(crate::floating::group_rect_id("color"))).unwrap();
|
||||
drag(&mut h, source, egui::pos2(color.center().x, color.bottom() + 2.0));
|
||||
assert!(h.state().ui.docking.is_some(), "successful first tear retains shared ownership");
|
||||
let groups = &h.state().ui.floating_panels;
|
||||
let color = groups.iter().find(|g| g.panels.iter().any(|p| p == "color")).unwrap();
|
||||
let swatches = groups.iter().find(|g| g.panels.iter().any(|p| p == "swatches")).unwrap();
|
||||
assert!(color.column.is_some());
|
||||
assert_eq!(swatches.column, color.column);
|
||||
assert!(valid(h.state().ui.docking.as_ref().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_set_drop_targets_respect_the_visible_layer_above_an_ordinary_float() {
|
||||
let mut app = app();
|
||||
app.run("window.panel.float", json!({"panel":"swatches","x":100,"y":100})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap();
|
||||
app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap();
|
||||
let h = harness(app, vec2(1280.0, 900.0));
|
||||
let rect = h.ctx.data(|data| data.get_temp::<egui::Rect>(crate::floating::group_rect_id("stroke"))).unwrap();
|
||||
let at = rect.center();
|
||||
assert_eq!(h.ctx.layer_id_at(at).unwrap().id, crate::floating::area_id("stroke"), "native set is visibly above the ordinary shared float");
|
||||
let target = crate::floating::drop_target(h.state(), &h.ctx, crate::floating::Moving::Panel("layers"), at).unwrap().0;
|
||||
assert!(
|
||||
matches!(target, crate::floating::Drop::Above("stroke") | crate::floating::Drop::Below("stroke")),
|
||||
"visible native group owns the drop: {target:?}"
|
||||
);
|
||||
}
|
||||
@@ -200,6 +200,7 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = ui.interact(rect, ui.id().with(("panel-menu", id)), Sense::click());
|
||||
icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim });
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panel menu")));
|
||||
let resp = resp.on_hover_text(tl!("Panel menu"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
crate::widgets::menu_scroll(ui, |ui| {
|
||||
|
||||
@@ -356,13 +356,11 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
});
|
||||
divider(ui);
|
||||
section_header(ui, tl!("Snap Options"));
|
||||
let mut sp = app.ui.view.snap_to_point;
|
||||
if ui.checkbox(&mut sp, tl!("Snap to Point")).changed() {
|
||||
app.ui.view.snap_to_point = sp;
|
||||
if widgets::check(ui, tl!("Snap to Point"), app.ui.view.snap_to_point, true) {
|
||||
app.ui.view.snap_to_point ^= true;
|
||||
}
|
||||
let mut sg = app.ui.view.snap_to_grid;
|
||||
if ui.checkbox(&mut sg, tl!("Snap to Grid")).changed() {
|
||||
app.ui.view.snap_to_grid = sg;
|
||||
if widgets::check(ui, tl!("Snap to Grid"), app.ui.view.snap_to_grid, true) {
|
||||
app.ui.view.snap_to_grid ^= true;
|
||||
}
|
||||
divider(ui);
|
||||
section_header(ui, tl!("Preferences"));
|
||||
@@ -373,9 +371,9 @@ fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.session.prefs.keyboard_increment = v.max(0.001);
|
||||
}
|
||||
});
|
||||
let mut ss = app.session.prefs.scale_strokes;
|
||||
if ui.checkbox(&mut ss, tl!("Scale Strokes & Effects")).changed() {
|
||||
super::transform::set_pref(app, "scaleStrokes", ss);
|
||||
let ss = app.session.prefs.scale_strokes;
|
||||
if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) {
|
||||
super::transform::set_pref(app, "scaleStrokes", !ss);
|
||||
}
|
||||
divider(ui);
|
||||
section_header(ui, tl!("Quick Actions"));
|
||||
@@ -584,6 +582,40 @@ mod tests {
|
||||
frame(app, ctx, vec![b(false)]);
|
||||
}
|
||||
|
||||
/// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every
|
||||
/// theme, as the Preferences ones do (#1080), and toggle when clicked.
|
||||
#[test]
|
||||
fn the_document_checkboxes_show_their_boxes_and_toggle() {
|
||||
for brightness in crate::theme::Brightness::ALL {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, brightness);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui));
|
||||
out.textures_delta.clear();
|
||||
let boxes = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.stroke.width > 0.0 && (r.rect.width() - 13.0).abs() < 0.5 && (r.rect.height() - 13.0).abs() < 0.5))
|
||||
.count();
|
||||
assert!(boxes >= 3, "{brightness:?}: {boxes} checkbox outlines");
|
||||
}
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
let (grid, strokes) = (app.ui.view.snap_to_grid, app.session.prefs.scale_strokes);
|
||||
click(&mut app, &ctx, &texts, "Snap to Grid");
|
||||
assert_eq!(app.ui.view.snap_to_grid, !grid);
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
click(&mut app, &ctx, &texts, "Scale Strokes & Effects");
|
||||
assert_eq!(app.session.prefs.scale_strokes, !strokes);
|
||||
}
|
||||
|
||||
/// #921: with the Gradient tool, a Gradient section: the type, and for a freeform gradient
|
||||
/// Draw as Points or Lines radio buttons.
|
||||
#[test]
|
||||
|
||||
@@ -363,9 +363,10 @@ fn picas_in_use(d: &Dialog) -> bool {
|
||||
}
|
||||
|
||||
fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
|
||||
let mut b = d.bool(key);
|
||||
if ui.checkbox(&mut b, tl!(label)).changed() {
|
||||
d.fields.insert(key.into(), json!(b));
|
||||
// The app's outlined checkbox (it translates `label`): egui's own has no border at rest.
|
||||
let on = d.bool(key);
|
||||
if widgets::check(ui, label, on, ui.is_enabled()) {
|
||||
d.fields.insert(key.into(), json!(!on));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +387,28 @@ mod tests {
|
||||
use crate::Services;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// A resting checkbox must have a visible outline in both states and every brightness.
|
||||
#[test]
|
||||
fn preference_checkboxes_draw_outlines_without_hover() {
|
||||
for brightness in Brightness::ALL {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, brightness);
|
||||
for checked in [false, true] {
|
||||
let mut d = Dialog::new("preferences", json!({"showToolTips": checked}));
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 200.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| bool_row(ui, &mut d, "showToolTips", "Show Tool Tips"));
|
||||
out.textures_delta.clear();
|
||||
let outlined = out.shapes.iter().any(|shape| match &shape.shape {
|
||||
egui::Shape::Rect(r) => r.stroke.width > 0.0 && r.rect.width() <= 24.0 && r.rect.height() <= 24.0,
|
||||
_ => false,
|
||||
});
|
||||
assert!(outlined, "{brightness:?}, checked={checked}: no checkbox outline");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User Interface › Canvas Color (#663): White and the three greys paint the canvas around the
|
||||
/// artboards; Match User Interface Brightness keeps the theme's.
|
||||
#[test]
|
||||
@@ -560,8 +583,9 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
assert!(shown.iter().any(|t| t == "System Title Bar"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
|
||||
let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos")));
|
||||
assert_eq!(shown.iter().any(|t| t == "System Title Bar"), supported, "{shown:?}");
|
||||
assert_eq!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), supported, "{shown:?}");
|
||||
a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true));
|
||||
confirm(&mut a).unwrap();
|
||||
assert!(a.session.prefs.system_title_bar);
|
||||
|
||||
@@ -277,6 +277,17 @@ pub struct UiState {
|
||||
/// Panels dragged out of the dock: each group floats as its own stack of tabs.
|
||||
#[serde(default)]
|
||||
pub floating_panels: Vec<FloatingPanels>,
|
||||
/// Shared panel membership and placement; older preferences use the legacy dock.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub docking: Option<craft_ui::docking::Layout<String>>,
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
|
||||
/// Panels whose saved return destination is the iconic rail.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
|
||||
pub docking_icons: std::collections::BTreeSet<String>,
|
||||
/// Invalidates in-flight gestures when a workspace is replaced.
|
||||
#[serde(skip)]
|
||||
pub docking_generation: u64,
|
||||
/// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`:
|
||||
/// docked at the window's left edge.
|
||||
#[serde(default)]
|
||||
@@ -534,6 +545,8 @@ impl UiState {
|
||||
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
|
||||
});
|
||||
FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos);
|
||||
self.docking = self.docking.filter(crate::panel_docking::valid);
|
||||
self.docking_icons.retain(|id| ICON_PANELS.iter().any(|entry| entry.0 == id));
|
||||
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
|
||||
// the default back.
|
||||
self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some());
|
||||
@@ -558,6 +571,10 @@ impl Default for UiState {
|
||||
slot_tool: Default::default(),
|
||||
floating_flyouts: vec![],
|
||||
floating_panels: vec![],
|
||||
docking: None,
|
||||
docking_hidden: Default::default(),
|
||||
docking_icons: Default::default(),
|
||||
docking_generation: 0,
|
||||
toolbar_pos: None,
|
||||
status_bar: true,
|
||||
dock: true,
|
||||
|
||||
@@ -302,18 +302,11 @@ pub fn apply(ctx: &egui::Context, b: Brightness) {
|
||||
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) };
|
||||
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) };
|
||||
let w = &mut v.widgets;
|
||||
for (wv, fill) in [
|
||||
(&mut w.noninteractive, t.panel),
|
||||
(&mut w.inactive, t.button),
|
||||
(&mut w.hovered, t.hover),
|
||||
(&mut w.active, t.tool_active),
|
||||
(&mut w.open, t.hover),
|
||||
] {
|
||||
wv.bg_fill = fill;
|
||||
wv.weak_bg_fill = fill;
|
||||
wv.corner_radius = t.cr();
|
||||
wv.fg_stroke = Stroke::new(1.0, t.text);
|
||||
}
|
||||
craft_ui::theme::WidgetPalette { noninteractive: t.panel, inactive: t.button, hovered: t.hover, active: t.tool_active, open: t.hover }.apply_to(
|
||||
w,
|
||||
t.cr(),
|
||||
Stroke::new(1.0, t.text),
|
||||
);
|
||||
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
|
||||
w.inactive.bg_stroke = Stroke::NONE;
|
||||
w.hovered.bg_stroke = Stroke::new(1.0, t.input_border);
|
||||
|
||||
@@ -982,12 +982,11 @@ pub fn link_label(ui: &mut Ui, text: &str) -> Response {
|
||||
resp
|
||||
}
|
||||
|
||||
/// The row of a checkbox or radio button: allocates a 13 pt box plus `label`, draws the label and
|
||||
/// returns the box, the response and the box's border colour.
|
||||
fn choice_row(ui: &mut Ui, label: &str, enabled: bool) -> (Rect, Response, Color32) {
|
||||
/// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated),
|
||||
/// draws the text and returns the box, the response and the box's border colour.
|
||||
fn choice_row(ui: &mut Ui, text: &str, enabled: bool) -> (Rect, Response, Color32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley =
|
||||
ui.painter().layout_no_wrap(tl!(label).to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled });
|
||||
let galley = ui.painter().layout_no_wrap(text.to_string(), egui::FontId::proportional(12.5), if enabled { t.text } else { t.text_disabled });
|
||||
let (rect, resp) =
|
||||
ui.allocate_exact_size(vec2(18.0 + galley.size().x, 20.0f32.max(galley.size().y)), if enabled { Sense::click() } else { Sense::hover() });
|
||||
let bx = Rect::from_min_size(pos2(rect.left(), rect.center().y - 6.5), Vec2::splat(13.0));
|
||||
@@ -1009,21 +1008,27 @@ pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool {
|
||||
|
||||
/// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled.
|
||||
pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool {
|
||||
let (clicked, resp) = check_row(ui, label, Some(value), enabled);
|
||||
let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled);
|
||||
resp.on_hover_text(tip);
|
||||
clicked
|
||||
}
|
||||
|
||||
/// Three-state [`check`]: `None` shows a dash (a neutral or mixed state). Returns true when clicked.
|
||||
pub fn check3(ui: &mut Ui, label: &str, value: Option<bool>, enabled: bool) -> bool {
|
||||
check_row(ui, label, value, enabled).0
|
||||
check_row(ui, tl!(label), value, enabled).0
|
||||
}
|
||||
|
||||
/// [`check3`], also returning the row's response (what a tooltip goes on: a widget around it never
|
||||
/// counts as hovered under the box).
|
||||
fn check_row(ui: &mut Ui, label: &str, value: Option<bool>, enabled: bool) -> (bool, Response) {
|
||||
/// [`check`] for a name of the document's (an artboard's, an asset's), shown as it is, never
|
||||
/// translated. Returns true when toggled.
|
||||
pub fn check_name(ui: &mut Ui, name: &str, value: bool) -> bool {
|
||||
check_row(ui, name, Some(value), true).0
|
||||
}
|
||||
|
||||
/// [`check3`] with its `text` already translated, also returning the row's response (what a
|
||||
/// tooltip goes on: a widget around it never counts as hovered under the box).
|
||||
fn check_row(ui: &mut Ui, text: &str, value: Option<bool>, enabled: bool) -> (bool, Response) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (bx, resp, border) = choice_row(ui, label, enabled);
|
||||
let (bx, resp, border) = choice_row(ui, text, enabled);
|
||||
let checked = value == Some(true);
|
||||
ui.painter().rect_filled(bx, CornerRadius::same(2), if checked && enabled { t.accent_strong } else { t.input });
|
||||
ui.painter().rect_stroke(bx, CornerRadius::same(2), Stroke::new(1.0, border), StrokeKind::Inside);
|
||||
@@ -1045,7 +1050,7 @@ fn check_row(ui: &mut Ui, label: &str, value: Option<bool>, enabled: bool) -> (b
|
||||
/// Radio button in the style of [`check`], with a disabled state. Returns true when clicked.
|
||||
pub fn radio(ui: &mut Ui, label: &str, selected: bool, enabled: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (bx, resp, border) = choice_row(ui, label, enabled);
|
||||
let (bx, resp, border) = choice_row(ui, tl!(label), enabled);
|
||||
let c = bx.center();
|
||||
ui.painter().circle(c, 6.0, if selected && enabled { t.accent_strong } else { t.input }, Stroke::new(1.0, border));
|
||||
if selected {
|
||||
|
||||
@@ -31,6 +31,14 @@ pub struct Workspace {
|
||||
pub floating_flyouts: Vec<FloatingFlyout>,
|
||||
/// Panels dragged out of the dock, where they float (built-in workspaces: none).
|
||||
pub floating_panels: Vec<FloatingPanels>,
|
||||
/// Custom docking tree, including floating tab groups.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub docking: Option<craft_ui::docking::Layout<String>>,
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub docking_hidden: std::collections::BTreeMap<String, craft_ui::docking::Location<String>>,
|
||||
/// Panels whose saved return destination is the iconic rail.
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
|
||||
pub docking_icons: std::collections::BTreeSet<String>,
|
||||
/// Where the Tools panel floats (built-in workspaces: docked).
|
||||
pub toolbar_pos: Option<[f32; 2]>,
|
||||
}
|
||||
@@ -128,6 +136,9 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
|
||||
status_bar: ui.status_bar,
|
||||
floating_flyouts: ui.floating_flyouts.clone(),
|
||||
floating_panels: ui.floating_panels.clone(),
|
||||
docking: ui.docking.clone(),
|
||||
docking_hidden: ui.docking_hidden.clone(),
|
||||
docking_icons: ui.docking_icons.clone(),
|
||||
toolbar_pos: ui.toolbar_pos,
|
||||
}
|
||||
}
|
||||
@@ -147,6 +158,11 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
|
||||
ui.status_bar = w.status_bar;
|
||||
ui.floating_flyouts = w.floating_flyouts.clone();
|
||||
ui.floating_panels = w.floating_panels.clone();
|
||||
ui.docking_generation = ui.docking_generation.wrapping_add(1);
|
||||
ui.docking = w.docking.clone().filter(crate::panel_docking::valid);
|
||||
ui.docking_hidden = w.docking_hidden.clone();
|
||||
ui.docking_icons =
|
||||
w.docking_icons.iter().filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())).take(64).cloned().collect();
|
||||
ui.toolbar_pos = w.toolbar_pos;
|
||||
FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos);
|
||||
ui.flyout = None;
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
`vectorcraft --control <port>` listens on `127.0.0.1:<port>` (loopback only). One JSON request per line:
|
||||
`{"id": 1, "method": "ui.inspect", "params": {}}` → `{"id": 1, "ok": true, "result": {...}}` or `{"id":1,"ok":false,"error":"..."}`.
|
||||
|
||||
Set `VECTORCRAFT_CONFIG_DIR` to a dedicated directory for an isolated native test profile. Preferences,
|
||||
logs, recovery and user libraries then use that root, without importing the legacy DrawCraft profile.
|
||||
An unset override preserves the usual platform paths and legacy migration; an explicitly empty override
|
||||
disables those paths. `VECTORCRAFT_NO_PREFS` continues to disable preference reads and writes.
|
||||
|
||||
**Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params`
|
||||
are optional; blank lines are skipped). Anything else gets one error reply
|
||||
(`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so
|
||||
@@ -697,3 +702,34 @@ drags its points (a press on a point focuses it, and a press on one of the focus
|
||||
handle); a Mesh tool click inside a mesh envelope adds a row and a column. While an envelope or its content is
|
||||
selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and
|
||||
distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command.
|
||||
|
||||
## Shared panel docking
|
||||
|
||||
Panel arrangements support tab grouping and reordering, horizontal and vertical splits,
|
||||
and movable, resizable floating groups inside the application window. Drag a panel tab,
|
||||
or use its context menu. Floating groups remain within the application viewport.
|
||||
|
||||
The shared layout begins with the existing workspace on the first docking operation.
|
||||
Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the
|
||||
positions of hidden panels. Resetting a built-in workspace restores its default dock.
|
||||
|
||||
- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group.
|
||||
`zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders
|
||||
tabs by panel ID. The destination must already be visible.
|
||||
- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel.
|
||||
- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous
|
||||
dock placement when possible, or to the supplied destination.
|
||||
- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved
|
||||
position when possible. `window.panel.close {panel}` hides it.
|
||||
|
||||
Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and
|
||||
invalid destinations return an error without changing the arrangement.
|
||||
|
||||
`window.panel.layout {action}` also accepts the shared, externally tagged action schema.
|
||||
For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}`
|
||||
updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}`
|
||||
resizes the root divider (`false`/`true` path entries select first/second children).
|
||||
`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and
|
||||
`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries.
|
||||
The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale
|
||||
split paths return an error without changing the workspace.
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Lab spot colours in `.ai` and PDF, #1032) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`.ai` artboard layout when read as its PDF part, #1068) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/mod.rs`). Illustrator's list comes from its public
|
||||
@@ -44,7 +44,7 @@ private editing data. VectorCraft:
|
||||
|
||||
### Illustrator's private editing data
|
||||
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths, clipping groups and layer clipping, object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), opacity, blend modes, isolation and knockout, embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text document, whole and editable, shown or not: point type, area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing; the page's drawing of it (often in pieces) is left out. Where the text document can't say (a frame of another kind, or a document that doesn't agree with the page), type that shows comes from the page into its text object's place (no group around it), and hidden type is left out with a note. Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. Symbols, pattern fills whose pattern it can't read, placed files and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5%, where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068). That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard.
|
||||
|
||||
## Format by format
|
||||
|
||||
@@ -80,6 +80,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | A `.ai` read as its PDF part keeps its artboard layout from the editing data (#1068) |
|
||||
| 2026-10-11 | minor | Spot colours defined in Lab keep their Lab values, from the editing data and from a PDF part whose ink alternate is an ICC Lab profile (#1032) |
|
||||
| 2026-10-11 | minor | `.ai` in PostScript form opens through its layers, also without its prolog (#1027) |
|
||||
| 2026-10-11 | minor | `.ai` editing data: global process colours and pattern fills read, so they no longer drop sublayers (#1025) |
|
||||
|
||||
@@ -396,6 +396,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Shaper:** uneven squares and rectangles become live rectangles even when drawing begins mid-edge. Recognition uses uniformly spaced samples and closed-loop corners, fits rectangle edges/angles rather than requiring a nearly perfect bounding-box fill, and includes the pointer-release position. Rectangles/squares and ellipses snap to 45° steps, hexagons to 90°, and triangles point up or down; lines keep their free angle even with Shift. Shaper Groups retain the live originals, merge regions with the gesture-origin fill, punch fills independently from strokes, remove boundaries on exiting scribbles and trim protruding stroke pieces. Source moves/resizes re-evaluate the result in the same undo step; double-click enters construction editing. Native save/reopen, face recoloring, Release and Expand are covered. The construction-mode widget and finer gesture heuristics remain.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784).
|
||||
Align and Distribute Spacing keep a compound path or selected group that contains the key object in place, as they keep the key object itself.
|
||||
Clear deletes individual selected compound-path members without deleting their siblings, and explicit object ids work without a selection; Cut still removes the same objects it copies (#786).
|
||||
- **Saved selections:** Select ▸ Save Selection… asks for a name and keeps the selection in the document (saved with the file, undoable, up to 25); each one is listed at the bottom of the Select menu and a click selects its objects again; Edit Selection… lists them to rename or delete, applied together on OK. A file's saved selections are capped and checked against its objects when it opens. Not yet: Update Selection and the Layers panel's saved-selection menu.
|
||||
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. Direction handles dragged with Direct Selection or the Anchor Point tool snap to smart guides, and Shift keeps them at 45° steps from their anchor. The Pen continues any open path from the end clicked, and while drawing, a click on an end of another open path joins the two into one (#776). The Curvature tool edits any selected path, whatever drew it, keeping its shape except where edited (#798): a click on a point makes it current and a drag moves it, re-curving only its two segments; Alt-click or double-click toggles it smooth or corner and Delete removes it; a click on a segment adds a point without changing the shape; with an end of an open path current, clicks go on from it and its other end closes it (`path.curvatureEdit`). Every point a drawing tool places snaps to Smart Guides through one snapper (#506): the Pen's and Curvature tool's anchors, the Pencil's ends, the shape tools' start and dragged corner or radius (Rectangle, Rounded Rectangle, Ellipse, Polygon, Star, Line Segment, Arc, Spiral, the grids), the Type tool's click and frame, a new artboard's corners.
|
||||
|
||||
+3
-2
@@ -92,7 +92,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy*"];
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 6;
|
||||
@@ -305,11 +305,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ui_crates_below_l6_flagged() {
|
||||
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
|
||||
for dep in ["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy_ecs", "bevy"] {
|
||||
let v = check(&[c("vectorcraft-engine", &[(dep, Normal, false)])]);
|
||||
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
|
||||
}
|
||||
assert!(check(&[c("vectorcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
|
||||
assert!(check(&[c("vectorcraft-ui-egui", &[("craft-ui", Normal, false)])]).is_empty());
|
||||
assert!(check(&[c("vectorcraft-mcp", &[("winit", Normal, false)])]).is_empty());
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user