Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e139af19 | ||
|
|
389f75fa7b | ||
|
|
34eb19a5ab | ||
|
|
0c57b1d5d2 | ||
|
|
5d847ffc88 | ||
|
|
2a52b2901f | ||
|
|
7f4f7bd8d2 | ||
|
|
2729483851 | ||
|
|
5226d5a728 | ||
|
|
dec085e1e5 | ||
|
|
eaf96c60ae | ||
|
|
85bbc35176 | ||
|
|
3ce51a3487 | ||
|
|
a031ff6c7b | ||
|
|
0d748500d6 | ||
|
|
d47857a20f | ||
|
|
c9236ed3f7 | ||
|
|
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). 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-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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -323,11 +323,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, _| {
|
||||
|
||||
@@ -161,12 +161,6 @@ pub(crate) fn parse_freeform(v: &Value) -> std::result::Result<Freeform, String>
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// `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.
|
||||
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 parse_mode(m: &str) -> std::result::Result<FreeformMode, String> {
|
||||
FreeformMode::parse(m).ok_or_else(|| format!("unknown freeform mode `{m}` (points, lines)"))
|
||||
}
|
||||
|
||||
@@ -340,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()?))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,31 @@ 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_moved_onto_another_makes_a_discontinuous_point_that_renders_a_step() {
|
||||
let (mut s, id) = line_session();
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
@@ -4007,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
|
||||
@@ -4060,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.
|
@@ -4047,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.
|
||||
|
||||
@@ -4105,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.
|
@@ -4059,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
|
||||
@@ -4115,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.
|
@@ -4049,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.
|
||||
|
||||
@@ -4107,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.
|
@@ -4042,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.
|
||||
|
||||
@@ -4100,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.
|
@@ -4045,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.
|
||||
|
||||
@@ -4103,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.
|
@@ -4030,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 Освітлення
|
||||
@@ -4086,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.
|
||||
+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