diff --git a/Cargo.lock b/Cargo.lock index bebf138cc..e278722b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -950,7 +950,7 @@ dependencies = [ [[package]] name = "craft-atomic" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e#e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "rustix 1.1.5", ] @@ -958,7 +958,7 @@ dependencies = [ [[package]] name = "craft-cms" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e#e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "rayon", ] @@ -966,12 +966,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "bytemuck", "hayro", @@ -984,7 +984,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -997,16 +997,20 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "egui", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "egui", "objc2 0.6.4", @@ -2467,7 +2471,7 @@ dependencies = [ [[package]] name = "hayro" version = "0.7.1" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "bytemuck", "hayro-interpret", @@ -2487,7 +2491,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "brotli", "hayro-postscript", @@ -2496,7 +2500,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -2514,7 +2518,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -2532,13 +2536,12 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "flate2", "hayro-ccitt", @@ -3039,7 +3042,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" +source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index ad1542e9f..afa0d7be9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ repository = "https://github.com/storytold/designcraft" [workspace.dependencies] # Shared reader and writer; publish the immutable revision before this consumer. -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.1.0", default-features = false } # internal designcraft-geom = { path = "crates/geom" } designcraft-color = { path = "crates/color" } @@ -75,20 +75,20 @@ zip = { version = "3", default-features = false, features = ["deflate"] } indd = { git = "https://github.com/storytold/indd-utils", branch = "main" } # Which paths stored in documents may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } # Atomic file replacement for saves and exports (craft-libs). -craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } +craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } # File › Print through the system print spooler (craft-libs). -craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } +craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } # ICC colour management: working spaces, transforms, black point compensation (craft-libs). -craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" } +craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } # ui # Shared frontend components; keep this dependency above the engine boundary. -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } -# The application bar as the window's title bar (craft-libs); its egui 0.37 port. -craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["egui"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] } +# The application bar as the window's title bar (craft-libs). +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["egui"] } egui = "0.37" egui_extras = { version = "0.37", default-features = false, features = ["svg"] } eframe = { version = "0.37", default-features = false } @@ -126,13 +126,14 @@ debug = 0 # Opt-in cancelable macOS termination on the pinned winit 0.30 line. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.8.2" } winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } -hayro = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.1" } -hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.0" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.7.2" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.3.0" } -hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0" } +hayro = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.7.1" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.7.0" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.7.2" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.3.0" } +hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.1.0" } +hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", version = "=0.1.0" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/ROADMAP.md b/ROADMAP.md index e4e2b626c..6a3696361 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -194,7 +194,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). -- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. +- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable floating window that resizes from every edge; Dock Panel (in its tab menu and ≡ menu) puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`). - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. @@ -381,6 +381,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · Panels dock through the suite's shared docking (craft-libs), and on desktop a floating panel opens in its own window that can leave the main window: keys pressed there run the main window's shortcuts, clipboard and tool keys, and the canvas paints no tool cursor while another of the app's windows has the pointer. The web build keeps floating panels in the page. No readiness percentage change. - **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change. - **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change. - **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change. diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 965fa601d..12f423d79 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -147,15 +147,47 @@ fn prefs_path() -> Option { profile::config_dir().map(|b| b.join("ui.json")) } +/// Set when `ui.json` could not be read in full and no copy of it could be kept: the file is +/// then left as it is rather than overwritten. +static KEEP_UI_PREFS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Copy an unreadable (or partly unreadable) `ui.json` beside it, as `ui.unreadable.json` or the +/// first free `ui.unreadable-N.json`, before the next save replaces it. A newer DesignCraft may +/// have written it. +fn keep_unreadable_prefs(path: &std::path::Path, bytes: &[u8]) { + use std::io::Write as _; + for n in 1..100u32 { + let name = if n == 1 { "ui.unreadable.json".to_string() } else { format!("ui.unreadable-{n}.json") }; + match std::fs::OpenOptions::new().write(true).create_new(true).open(path.with_file_name(name)) { + Ok(mut file) => { + if file.write_all(bytes).is_ok() { + return; + } + break; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(_) => break, + } + } + KEEP_UI_PREFS.store(true, std::sync::atomic::Ordering::Relaxed); +} + fn load_prefs(app: &mut DesignApp) { if no_prefs() { return; } if let Some(p) = prefs_path() && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = designcraft_ui_egui::UiState::from_preferences_json(&bytes) { - app.ui = ui; + match designcraft_ui_egui::UiState::read_preferences(&bytes) { + Ok(read) => { + app.ui = read.ui; + if !read.intact { + keep_unreadable_prefs(&p, &bytes); + } + } + Err(_) => keep_unreadable_prefs(&p, &bytes), + } } // Engine preferences (Preferences dialog, favourites…) live beside the UI state. if let Some(p) = prefs_path().map(|p| p.with_file_name("prefs.json")) @@ -172,7 +204,9 @@ fn save_prefs(app: &DesignApp) { } if let Some(p) = prefs_path() { let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new("."))); - if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) { + if !KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) + && let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) + { let _ = std::fs::write(&p, bytes); } if let Ok(bytes) = serde_json::to_vec_pretty(&app.session.prefs) { @@ -579,6 +613,22 @@ fn main() -> std::process::ExitCode { #[cfg(test)] mod tests { + #[test] + fn an_unreadable_ui_json_is_kept_beside_it() { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("designcraft-ui-prefs-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("ui.json"); + super::keep_unreadable_prefs(&path, b"{\"docking\": {\"root\": {\"FutureLayout\": {}}}}"); + super::keep_unreadable_prefs(&path, b"not json"); + let first = std::fs::read(dir.join("ui.unreadable.json")).unwrap(); + let second = std::fs::read(dir.join("ui.unreadable-2.json")).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert!(first.starts_with(b"{\"docking\""), "the first copy is not replaced"); + assert_eq!(second, b"not json"); + assert!(!super::KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed), "ui.json can be saved once a copy is kept"); + } + #[test] fn relink_open_dialog_lists_graphics() { let filters = super::open_filters("relink"); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 1dfb7f44c..0099e9b36 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -463,6 +463,7 @@ fn file_info_metadata(doc: &Document, title: Option<&str>, author: Option<&str>) description: (!description.is_empty()).then(|| description.to_string()), keywords: info.keywords.clone(), created: None, + modified: None, } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 3a40a9b84..fb236fb6c 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -497,6 +497,7 @@ fn paint_order(bytes: &[u8]) -> Vec<(String, f32)> { GlyphDrawMode::Fill => ("fill text".into(), 0.0), GlyphDrawMode::Stroke(p) => ("stroke text".into(), p.line_width), GlyphDrawMode::Invisible => ("invisible text".into(), 0.0), + GlyphDrawMode::Clip => ("clip text".into(), 0.0), }); } fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {} diff --git a/crates/render/src/pdf_bounds.rs b/crates/render/src/pdf_bounds.rs index 17881df6e..4af5737a2 100644 --- a/crates/render/src/pdf_bounds.rs +++ b/crates/render/src/pdf_bounds.rs @@ -105,7 +105,8 @@ impl<'a> Device<'a> for Bounds { fn draw_glyph(&mut self, glyph: &Glyph<'a>, transform: Affine, glyph_transform: Affine, paint: &Paint<'a>, mode: &GlyphDrawMode) { let mode = match mode { - GlyphDrawMode::Invisible => return, + // Clipping text paints nothing; its clip arrives through `push_clip_path`. + GlyphDrawMode::Invisible | GlyphDrawMode::Clip => return, GlyphDrawMode::Fill => PathDrawMode::Fill(FillRule::NonZero), GlyphDrawMode::Stroke(s) => PathDrawMode::Stroke(s.clone()), }; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d8c0178e7..0a826b69e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,7 +10,7 @@ description = "DesignCraft egui frontend (InDesign-style UI); swappable — noth workspace = true [dependencies] -craft-ui = { workspace = true } +craft-ui = { workspace = true, features = ["native-windows"] } craft-window = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2cfedfaa3..331b83838 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -685,9 +685,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { return; } let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout); + // Where the pointer moved on to another of the app's windows, this window's last pointer + // position is stale: no hover feedback or tool cursor there. + let elsewhere = pointer_elsewhere(app, ui.ctx()); if !preview { // Content grabbers: the selected frame's and the hovered one's (Selection tool). - let hover = ui.ctx().pointer_hover_pos().filter(|p| screen.contains(*p)).map(|p| xf.to_canvas(p)); + let hover = ui.ctx().pointer_hover_pos().filter(|p| screen.contains(*p) && !elsewhere).map(|p| xf.to_canvas(p)); let vi = app.view_info(); for c in app.session.content_grabbers(hover, vi) { let c = xf.to_screen(c); @@ -709,7 +712,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { crate::taskbar::show(app, &ui.ctx().clone(), r, screen); } // Cursor. - if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) { + if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p) && !elsewhere) { let wants_text = app.session.wants_text(); let space = navigation_space(app, ui.ctx()); let zoom = spring_zoom_mode(app, ui.ctx(), space); @@ -750,6 +753,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// Whether the pointer this canvas's window last saw is in another of the app's windows: a +/// floating panel's OS window, or the other window of New Window. +fn pointer_elsewhere(app: &DesignApp, ctx: &egui::Context) -> bool { + let mut others = crate::panel_docking::native_viewports(app, ctx); + if app.second_window && !ctx.embed_viewports() { + others.extend([egui::ViewportId::ROOT, crate::second_window_viewport()]); + } + crate::cursors::pointer_elsewhere(ctx, &others) +} + /// The loaded text cursor's badge beside the system cursor: lines of text, a closed chain /// (a click threads) or a broken one (a click unthreads). Drawn in code. fn draw_cursor_badge(painter: &egui::Painter, p: Pos2, c: Cursor, t: &Tokens) { @@ -2184,7 +2197,7 @@ fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, k += 1; } // Pointer position markers. - if let Some(p) = ui.ctx().pointer_hover_pos().filter(|p| rect.contains(*p)) { + if let Some(p) = ui.ctx().pointer_hover_pos().filter(|p| rect.contains(*p) && !pointer_elsewhere(app, ui.ctx())) { let m = Stroke::new(1.0, t.text_dim); dashed(&painter, pos2(p.x, top.min.y), pos2(p.x, top.max.y), m, 1.5, 1.5); dashed(&painter, pos2(left.min.x, p.y), pos2(left.max.x, p.y), m, 1.5, 1.5); @@ -2746,30 +2759,7 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r crate::menus::activate(app, id, ¶ms); } } - egui::Event::Key { key, pressed: true, modifiers, .. } => { - let k = match key { - egui::Key::ArrowLeft => Some(ToolKey::Left), - egui::Key::ArrowRight => Some(ToolKey::Right), - egui::Key::ArrowUp => Some(ToolKey::Up), - egui::Key::ArrowDown => Some(ToolKey::Down), - egui::Key::Enter => Some(ToolKey::Enter), - egui::Key::Escape => Some(ToolKey::Escape), - egui::Key::Backspace => Some(ToolKey::Backspace), - egui::Key::Delete => Some(ToolKey::Delete), - egui::Key::Tab => Some(ToolKey::Tab), - egui::Key::Home => Some(ToolKey::Home), - egui::Key::End => Some(ToolKey::End), - _ => None, - }; - if let Some(k) = k { - let m = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command, ctrl: modifiers.ctrl, space: false }; - let handled = app.session.tool_key(k, m, vi).unwrap_or(false); - if !handled && k == ToolKey::Escape { - let _ = app.run("edit.deselectAll", json!({})); - } - app.after_engine(); - } - } + egui::Event::Key { key, pressed: true, modifiers, .. } => tool_key(app, key, modifiers, vi), _ => {} } } @@ -2777,6 +2767,66 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let _ = DVec2::ZERO; } +/// A key for the active tool (caret movement, nudging, Backspace); Escape it leaves alone +/// deselects. +fn tool_key(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, vi: designcraft_engine::ViewInfo) { + let k = match key { + egui::Key::ArrowLeft => ToolKey::Left, + egui::Key::ArrowRight => ToolKey::Right, + egui::Key::ArrowUp => ToolKey::Up, + egui::Key::ArrowDown => ToolKey::Down, + egui::Key::Enter => ToolKey::Enter, + egui::Key::Escape => ToolKey::Escape, + egui::Key::Backspace => ToolKey::Backspace, + egui::Key::Delete => ToolKey::Delete, + egui::Key::Tab => ToolKey::Tab, + egui::Key::Home => ToolKey::Home, + egui::Key::End => ToolKey::End, + _ => return, + }; + let m = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command, ctrl: modifiers.ctrl, space: false }; + let handled = app.session.tool_key(k, m, vi).unwrap_or(false); + if !handled && k == ToolKey::Escape { + let _ = app.run("edit.deselectAll", json!({})); + } + app.after_engine(); +} + +/// The keys a floating panel's own OS window left over once the shortcuts took theirs: the +/// clipboard and the active tool's keys, as the canvas handles them while no text is edited. +/// `modifiers` are those held in that window; egui-winit sends ⌘C, ⌘X and ⌘V as `Copy`, `Cut` +/// and `Paste` without their key press. +pub(crate) fn forwarded_keys(app: &mut DesignApp, events: Vec, modifiers: egui::Modifiers) { + if !keyboard_pane(app) || modal_open(app) || app.session.wants_text() || app.session.tool_composing() { + return; + } + let vi = app.view_info(); + for e in events { + #[cfg(not(target_arch = "wasm32"))] + let chord = if matches!(e, egui::Event::Paste(_)) { crate::menus::paste_chord_command(app, modifiers) } else { None }; + // The web keeps floating panels in the page, so nothing is forwarded there. + #[cfg(target_arch = "wasm32")] + let chord: Option<&str> = { + let _ = modifiers; + None + }; + let (id, params) = match (e, chord) { + (egui::Event::Paste(_), Some(id)) => (id, serde_json::Value::Null), + (egui::Event::Copy, _) if !app.native_menu => ("edit.copy", serde_json::Value::Null), + (egui::Event::Cut, _) if !app.native_menu => ("edit.cut", serde_json::Value::Null), + (egui::Event::Paste(t), _) if !app.native_menu => ("edit.paste", json!({"text": t})), + (egui::Event::Key { key, pressed: true, modifiers, .. }, _) => { + tool_key(app, key, modifiers, vi); + continue; + } + _ => continue, + }; + if crate::menus::enabled(app, id) { + crate::menus::activate(app, id, ¶ms); + } + } +} + /// A dialog or Quick Apply has the keyboard. The canvas keeps egui's focus behind a modal, and it /// runs before the modal draws, so it must not also act on the Esc or Return that closes it. fn modal_open(app: &DesignApp) -> bool { @@ -5796,16 +5846,19 @@ mod tests { let out = &h.output().platform_output; (out.cursor_icon, out.cursor_image.clone()) }; - let (pen, direct, text) = (shown("pen"), shown("directSelection"), shown("type")); + let (pen, direct, text, selection) = (shown("pen"), shown("directSelection"), shown("type"), shown("selection")); let ppp = h.ctx.pixels_per_point(); let images = &mut h.state_mut().cursors; match crate::cursors::mode() { crate::cursors::Mode::Os => { assert_eq!(pen.1, images.get(Cursor::Pen, ppp), "Pen"); assert_eq!(direct.1, images.get(Cursor::ArrowHollow, ppp), "Direct Selection"); + assert_eq!(selection.1, images.get(Cursor::Arrow, ppp), "Selection"); assert_eq!(text, (egui::CursorIcon::Text, None), "the I-beam stays a system cursor"); } - crate::cursors::Mode::Painted => assert_eq!((pen.0, direct.0), (egui::CursorIcon::None, egui::CursorIcon::None)), + crate::cursors::Mode::Painted => { + assert_eq!((pen.0, direct.0, selection.0), (egui::CursorIcon::None, egui::CursorIcon::None, egui::CursorIcon::None)); + } crate::cursors::Mode::System => assert_eq!(pen, (egui::CursorIcon::Crosshair, None)), } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dd7ef2807..dc8a69b46 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1102,10 +1102,9 @@ pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Show the Preflight panel. `window.panel` toggles a panel that is already open, so it runs -/// only when Preflight isn't showing. +/// Show the Preflight panel. An open Preflight flyout stays: `window.panel` would close it. fn show_preflight(app: &mut DesignApp) { - if app.ui.open_panel.as_deref() != Some("preflight") + if app.ui.docking_flyout.as_deref() != Some("preflight") && let Err(e) = app.run("window.panel", json!({"panel": "preflight"})) { app.status(e); @@ -1653,15 +1652,15 @@ mod tests { h.run_steps(3); h.get_by_label("Preflight Panel…").click(); h.run_steps(3); - // Preflight is not a dock tab: `window.panel` shows it as its own panel. - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); - h.get_by_label("Preflight"); + // Preflight is in the iconic column: `window.panel` shows it. + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); + assert!(h.query_all_by_label("Preflight").count() >= 2, "its icon and its tab in the flyout"); // With the panel already showing, the entry keeps it open. h.get_by_label("No errors ▾").click(); h.run_steps(3); h.get_by_label("Preflight Panel…").click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } /// As in InDesign, double-clicking the preflight count (or its light) shows the panel. @@ -1675,15 +1674,18 @@ mod tests { count.click(); count.click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); - h.get_by_label("Preflight"); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); + assert!(h.query_all_by_label("Preflight").count() >= 2, "its icon and its tab in the flyout"); assert!(h.query_by_label("Preflight Panel…").is_none(), "the menu isn't left open"); - // A double-click with the panel already showing keeps it open. + // A double-click with the panel already showing keeps it open (the first click closes + // its flyout, the double-click opens it again). Past the double-click time, so the + // clicks are not counted with the ones before. + h.run_steps(30); let count = h.get_by_label("No errors ▾"); count.click(); count.click(); h.run_steps(3); - assert_eq!(h.state().app.ui.open_panel.as_deref(), Some("preflight")); + assert!(crate::panel_docking::showing(&h.state().app, "preflight")); } /// A very long document name made its tab grow past the window, hiding the close button diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f935873f8..0a0114ae0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -4,6 +4,7 @@ //! - `engine.execute {command, params}` / `ui.menu.invoke {command|id, params}`: run any command //! - `engine.commands`: engine + UI commands with enablement //! - `document.inspect`: document summary; `ui.inspect`: UI state +//! - `ui.docking`: the panel layout in use and the focused panel //! - `ui.menu.list`: flattened menu tree //! - `ui.tool.select {tool}`, `ui.tool.list` //! - `ui.pointer {events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen"}], mods?}`: @@ -77,6 +78,7 @@ pub fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { "ui": serde_json::to_value(&app.ui).unwrap_or_default(), "view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()), "canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), + "docking": crate::panel_docking::inspect(app, ctx), "window": [r.width(), r.height()], "documents": app .session @@ -169,6 +171,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", json!({}))), "ui.inspect" => ok(inspect(app, ctx)), + "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), "ui.menu.list" => ok(serde_json::to_value(menus::MENUS).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), "ui.tool.list" => ok(serde_json::to_value(designcraft_tools::TOOL_GROUPS).unwrap_or_default()), @@ -361,7 +364,7 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest r = crate::panel_docking::close_visible(app).map(|_| Value::Null); } if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) { - app.ui.dock_expanded = v; + r = crate::panel_docking::set_iconic(app, !v); } wrap(r) } diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 96cf7a116..3487a2673 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -7,6 +7,11 @@ //! so they never trail the pointer. macOS sizes a cursor bitmap in points (a Retina one would show //! twice as big, or blurred) and a Wayland session (native or through XWayland) scales it by its own //! rules, so there the canvas paints the glyph at the pointer. The web build keeps the system cursors. +//! +//! A painted glyph needs a live pointer position. macOS sends a window no pointer-left event when +//! the pointer moves onto another of the app's windows lying over it (a panel floating in its own +//! OS window), so the window's egui pointer stays where it was last seen; [`pointer_elsewhere`] +//! tells the canvas to paint nothing there. use egui::epaint::{Mesh, TessellationOptions, Tessellator, Vertex}; use egui::{Color32, CustomCursorImage, Pos2, Shape, Stroke, Vec2, pos2, vec2}; @@ -338,10 +343,78 @@ pub fn show(ctx: &egui::Context, images: &mut Images, c: Cursor, hover: Pos2) -> } } +/// Whether the pointer this viewport last saw has moved on to another of the app's windows, +/// `others` (the current viewport among them is skipped): from the frame one of them reports the +/// pointer until this viewport gets a pointer event again. That covers the pointer moving on from +/// the other window to the desktop too, where this one still hears nothing. Always false where +/// egui embeds viewports, so all of them share one window's pointer. +pub(crate) fn pointer_elsewhere(ctx: &egui::Context, others: &[egui::ViewportId]) -> bool { + let own = ctx.viewport_id(); + let key = egui::Id::unique(("pointer-elsewhere", own)); + let before = ctx.data(|d| d.get_temp::(key)).unwrap_or(false); + let here = ctx + .input(|i| i.events.iter().any(|e| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::PointerGone))); + // A pointer that arrived: moving or pressing there. A pointer that only stayed (a hidden + // window keeps its last one) does not count, so it never takes the pointer from this window. + let arrived = |i: &egui::InputState| { + i.pointer.has_pointer() && i.events.iter().any(|e| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. })) + }; + let separate = !ctx.embed_viewports() && others.iter().any(|&v| v != own); + // Where this window has pointer events of its own too, another window's report counts only + // if this window's pointer lies inside that window: one just outside it (its edge, where + // both may hear the pointer) keeps belonging here, frame after frame. + let at = ctx.input(|i| i.viewport().inner_rect.zip(i.pointer.latest_pos()).map(|(r, p)| r.min + p.to_vec2())); + let there = separate && others.iter().any(|&v| v != own && ctx.input_for(v, |i| arrived(i) && (!here || covers(i.viewport().inner_rect, at)))); + let now = separate && elsewhere(before, here, there); + if now != before { + ctx.data_mut(|d| d.insert_temp(key, now)); + } + now +} + +/// Whether a window at `rect` (desktop points) lies over the desktop point `at`; unknown when +/// either is, which counts as covering. +fn covers(rect: Option, at: Option) -> bool { + rect.zip(at).is_none_or(|(r, p)| r.contains(p)) +} + +/// [`pointer_elsewhere`] for one frame: whether the pointer belonged to another window `before`, +/// whether this window got a pointer event (`here`) and whether another window reports the +/// pointer arriving (`there`). Both in one frame mean the pointer just crossed into the other +/// window: this window's last events come from before the crossing. +fn elsewhere(before: bool, here: bool, there: bool) -> bool { + there || (before && !here) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn the_pointer_belongs_elsewhere_from_another_windows_report_until_this_one_hears_it_again() { + // Main window, then a floating panel's window over it, then the desktop, then back. + let mut state = false; + let mut frame = |here, there| { + state = elsewhere(state, here, there); + state + }; + assert!(!frame(true, false), "over the main window"); + assert!(frame(true, true), "crossing into the floating window"); + assert!(frame(false, true), "moving in the floating window"); + assert!(frame(false, false), "on the desktop: the main window still heard nothing"); + assert!(frame(false, false), "the next frame too"); + assert!(!frame(true, false), "back over the main window"); + assert!(!frame(false, false), "and resting there"); + } + + #[test] + fn a_pointer_just_outside_another_window_stays_this_windows() { + let float = Some(egui::Rect::from_min_size(egui::pos2(100.0, 100.0), egui::vec2(200.0, 300.0))); + assert!(covers(float, Some(egui::pos2(150.0, 150.0))), "over the floating window"); + assert!(!covers(float, Some(egui::pos2(98.0, 150.0))), "beside its left edge"); + assert!(covers(None, Some(egui::pos2(98.0, 150.0))) && covers(float, None), "unknown places count as covered"); + } + /// Every cursor with a glyph. const GLYPHS: [Cursor; 9] = [ Cursor::Arrow, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index aa8428469..7e2eb3d6c 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -1,16 +1,16 @@ -//! The right dock: an expanded panel group (Properties · Pages · Layers) and a collapsed icon -//! column whose panels open as flyouts beside the dock. +//! The panels: their ids, names, icons and contents, and the windows of the float-only panels. +//! `panel_docking` places the others in the docking layout. -use egui::{Color32, Sense, Stroke, vec2}; +use egui::{Sense, vec2}; use crate::theme::{Tokens, semibold}; -use crate::{DesignApp, icons, panels}; +use crate::{DesignApp, panels}; -/// (id, label, icon) of the tabs in the expanded group. +/// (id, label, icon) of the Properties · Pages · Layers group of the default dock. pub const DOCK_TABS: &[(&str, &str, &str)] = &[("properties", "Properties", "panel-properties"), ("pages", "Pages", "panel-pages"), ("layers", "Layers", "panel-layers")]; -/// Panels in the collapsed icon column. +/// (id, label, icon) of the other dock panels, which start in the iconic column. pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("stroke", "Stroke", "panel-stroke"), ("swatches", "Swatches", "panel-swatches"), @@ -51,7 +51,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("dataMerge", "Data Merge", "panel-datamerge"), ]; -/// Panels that only float (they open from a menu, not from the icon column). +/// Panels that only float, outside the docking layout (they open from a menu). pub const FLOATING_PANELS: &[(&str, &str)] = &[("tabs", "Tabs")]; /// The English name of panel `id`. @@ -59,202 +59,6 @@ pub fn panel_label(id: &str) -> &'static str { DOCK_TABS.iter().chain(ICON_PANELS).map(|p| (p.0, p.1)).chain(FLOATING_PANELS.iter().copied()).find(|p| p.0 == id).map_or("Panel", |p| p.1) } -pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { - if crate::panel_docking::show(app, ui) { - return; - } - let t = Tokens::get(ui.ctx()); - // Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right. - if app.ui.dock_expanded { - egui::Panel::right("dock").default_size(253.0).size_range(220.0..=420.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel)).show( - ui, - |ui| { - if dock_header(ui, &t, "»").clicked() { - let _ = app.run("window.collapseToIcons", serde_json::json!({})); - } - // Tab strip (27 pt). - let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.tab_strip); - let mut x = strip.min.x; - for (id, label, _) in DOCK_TABS { - let active = app.ui.dock_tab == *id; - let g = crate::rtl::plain( - ui.ctx(), - crate::i18n::tr(&app.ui.language, label), - semibold(11.5), - if active { t.text_strong } else { t.text_dim }, - ); - let w = g.size().x + 26.0; - let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0)); - if active { - ui.painter().rect_filled(r, 0.0, t.panel); - } - ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); - ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - let response = craft_ui::tabs::Tab::new(ui.scope_id().with(("docktab", *id)), label, active) - .sense(Sense::click_and_drag()) - .show_at(ui, r, |_, _| {}); - if response.clicked() { - app.ui.dock_tab = id.to_string(); - } - crate::panel_docking::legacy_tab(app, ui, id, &response); - x += w; - } - let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - let menu = ui.interact(menu_r, ui.scope_id().with("dock-menu"), Sense::click()); - if menu.hovered() { - ui.painter().rect_filled(menu_r, 3.0, t.hover); - } - icons::paint(ui.painter(), menu_r, "menu", t.icon); - let tab = app.ui.dock_tab.clone(); - egui::Popup::menu(&menu) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show(|ui| crate::panel_docking::three_line_menu(app, ui, &tab)); - egui::Frame::NONE.inner_margin(egui::Margin { left: 10, right: 10, top: 4, bottom: 6 }).show(ui, |ui| { - // The Pages panel scrolls its parts itself and keeps its footer at the bottom. - if app.ui.dock_tab == "pages" { - panels::pages::show(app, ui); - return; - } - egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| match app.ui.dock_tab.as_str() { - "layers" => panels::layers::show(app, ui), - _ => panels::properties::show(app, ui), - }); - }); - }, - ); - } - // Collapsed icon strip (37 pt) to the left of the panel stack; a workspace's iconic groups - // (Essentials Classic) show their names. - let groups = app.ui.icon_groups.clone(); - egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { - if dock_header(ui, &t, "«").clicked() { - app.ui.dock_expanded = true; - } - ui.add_space(4.0); - match &groups { - Some(groups) => { - for (i, group) in groups.iter().enumerate() { - if i > 0 { - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover()); - ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); - } - for (id, label, icon) in group.iter().filter_map(|id| panel_entry(id)) { - let on = app.ui.open_panel.as_deref() == Some(id) || app.ui.floating.iter().any(|(p, _)| p == id); - let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag()); - if on { - ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.tool_active); - } else if response.hovered() { - ui.painter().rect_filled(r.shrink2(vec2(3.0, 1.0)), 3.0, t.hover); - } - icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(8.0, 4.0), vec2(18.0, 18.0)), icon, t.icon); - crate::rtl::paint( - ui.painter(), - r.left_center() + vec2(32.0, 0.0), - egui::Align2::LEFT_CENTER, - crate::i18n::tr(&app.ui.language, label), - semibold(11.5), - t.text, - ); - icon_clicked(app, ui, id, &response); - } - } - } - None => { - ui.vertical_centered(|ui| { - for (id, label, icon) in ICON_PANELS { - let on = app.ui.open_panel.as_deref() == Some(*id) || app.ui.floating.iter().any(|(p, _)| p == id); - let response = icons::button(ui, icon, 28.0, on, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); - icon_clicked(app, ui, id, &response); - ui.add_space(1.0); - } - }); - } - } - }); -} - -/// Width of the icon column: icons only, or icons with names for a workspace's groups. -fn icon_column_width(app: &DesignApp) -> f32 { - if app.ui.icon_groups.is_some() { 150.0 } else { 37.0 } -} - -/// (id, label, icon) of any dock panel. -fn panel_entry(id: &str) -> Option<(&'static str, &'static str, &'static str)> { - DOCK_TABS.iter().chain(ICON_PANELS).find(|p| p.0 == id).copied() -} - -/// A click on a panel's icon: raise it when it floats, else open or close its flyout. -fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egui::Response) { - if response.clicked() { - if app.ui.floating.iter().any(|(p, _)| p == id) { - ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("floating_panel", id)))); - } else { - let open = app.ui.open_panel.as_deref() == Some(id); - app.ui.open_panel = if open { None } else { Some(id.to_string()) }; - } - } - crate::panel_docking::legacy_tab(app, ui, id, response); -} - -/// 11 pt dock header strip with a collapse chevron; clicking it reports a click on the response. -fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) -> egui::Response { - let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::click()); - ui.painter().rect_filled(r, 0.0, t.tab_strip); - ui.painter().text(egui::pos2(r.max.x - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, chevron, egui::FontId::proportional(9.0), t.text_dim); - ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); - response -} - -/// A panel opened from the icon column, shown as a floating flyout next to the dock. -pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { - if app.ui.docking.is_some() { - return; - } - let Some(id) = app.ui.open_panel.clone() else { return }; - let t = Tokens::get(ctx); - let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); - let screen = ctx.content_rect(); - let dock_w = if app.ui.dock_expanded { ctx.memory(|m| m.area_rect(egui::Id::unique("dock")).map(|r| r.width())).unwrap_or(280.0) } else { 0.0 }; - let pos = egui::pos2(screen.max.x - icon_column_width(app) - dock_w - 262.0, 120.0); - let mut open = true; - egui::Area::new(egui::Id::unique("panel_flyout")).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { - egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(0)).show(ui, |ui| { - ui.set_width(256.0); - // A previous shorter flyout must not constrain the next panel's scroll viewport. - ui.set_max_height((screen.max.y - pos.y - 12.0).max(80.0)); - let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover()); - ui.painter().rect_filled(strip, 0.0, t.panel_darker); - crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 13.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong); - let close = egui::Rect::from_min_size(egui::pos2(strip.max.x - 22.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - if ui.interact(close, ui.scope_id().with("flyclose"), Sense::click()).clicked() { - open = false; - } - ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); - // Float: the button beside the close box, or drag the header away from the dock. - let float_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 4.0), vec2(18.0, 18.0)); - let fr = ui.interact(float_r, ui.scope_id().with("flyfloat"), Sense::click()).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Float panel")); - }); - float_glyph(ui.painter(), float_r, t.text_dim); - let head = egui::Rect::from_min_max(strip.min, egui::pos2(float_r.min.x - 2.0, strip.max.y)); - let hr = ui.interact(head, ui.scope_id().with("flyhead"), Sense::drag()); - let torn = hr.drag_stopped() && hr.total_drag_delta().is_some_and(|d| d.length() > 24.0); - if fr.clicked() || torn { - let at = if torn { ui.ctx().pointer_latest_pos().unwrap_or(pos) - vec2(20.0, 10.0) } else { pos - vec2(30.0, -30.0) }; - float_panel(app, &id, at); - return; - } - egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { - scrolled_body(app, ui, &id, screen.height() - 220.0); - }); - }); - }); - if !open { - app.ui.open_panel = None; - } -} - /// A panel's contents at most `max_h` tall, scrolling past that. The Pages panel scrolls its /// parts itself and keeps its footer at the bottom, so it gets the height instead. fn scrolled_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, max_h: f32) { @@ -322,42 +126,16 @@ pub(crate) fn float_only(id: &str) -> bool { FLOATING_PANELS.iter().any(|p| p.0 == id) } -/// Tear panel `id` off the dock into a floating window at `at`. -pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { - if float_only(id) { - app.ui.floating.retain(|(p, _)| p != id); - app.ui.floating.push((id.to_string(), [at.x, at.y])); - return; - } - if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) { - app.status(error); - } -} - -/// Put a floating panel back in the dock's icon column. -pub fn dock_panel(app: &mut DesignApp, id: &str) { - if app.ui.docking.is_some() && !float_only(id) { - if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) { - app.status(error); - } - return; - } - app.ui.floating.retain(|(p, _)| p != id); -} - -/// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. -pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { - // A customized layout draws its own floating groups; float-only panels are drawn here. - let customized = app.ui.docking.is_some(); +/// The float-only panels' windows (the Tabs panel). The docking layout draws the other +/// floating panels. +pub fn float_only_windows(app: &mut DesignApp, ctx: &egui::Context) { let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { - if customized && !float_only(&id) { + if !float_only(&id) { continue; } let label = crate::i18n::tr(&app.ui.language, panel_label(&id)).to_owned(); - let docks = !float_only(&id); let mut open = true; - let mut dock = false; let mut w = egui::Window::new(label.clone()) .id(egui::Id::unique(("floating_panel", &id))) .title_bar(false) @@ -372,7 +150,7 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { w = w.current_pos(pos).min_width(width).max_width(width); } let r = w.show(ctx, |ui| { - // Header strip (drag it to move the panel): name, Dock, close. + // Header strip (drag it to move the panel): name and close. let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width().max(200.0), 24.0), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.panel_darker); crate::rtl::paint(ui.painter(), strip.min + vec2(10.0, 12.0), egui::Align2::LEFT_CENTER, &label, semibold(12.0), t.text_strong); @@ -387,20 +165,6 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { open = false; } ui.painter().text(close.center(), egui::Align2::CENTER_CENTER, "×", egui::FontId::proportional(15.0), t.text_dim); - let dock_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 44.0, strip.min.y + 3.0), vec2(18.0, 18.0)); - if docks - && ui - .interact(dock_r, ui.scope_id().with("fdock"), Sense::click()) - .on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Dock panel")); - }) - .clicked() - { - dock = true; - } - if docks { - dock_glyph(ui.painter(), dock_r, t.text_dim); - } egui::Frame::NONE.inner_margin(egui::Margin::same(10)).show(ui, |ui| { scrolled_body(app, ui, &id, ctx.content_rect().height() - 160.0); }); @@ -411,29 +175,16 @@ pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { { f.1 = [r.response.rect.min.x, r.response.rect.min.y]; } - if !open || dock { - dock_panel(app, &id); + if !open { + app.ui.floating.retain(|(p, _)| *p != id); } } } -/// Two overlapping boxes: "float this panel". -fn float_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { - let s = Stroke::new(1.0, c); - p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(3.5, 6.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle); - p.rect_stroke(egui::Rect::from_min_size(r.min + vec2(6.5, 3.5), vec2(9.0, 8.0)), 0.0, s, egui::StrokeKind::Middle); -} - -/// A box with a bar on its right: "back into the dock". -fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { - let b = egui::Rect::from_min_size(r.min + vec2(3.5, 4.5), vec2(11.0, 9.0)); - p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle); - p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c); -} - #[cfg(test)] mod visual_regressions { use super::*; + use egui::Color32; 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() }; @@ -449,9 +200,7 @@ mod visual_regressions { for brightness in crate::theme::Brightness::ALL { for width in [800.0, 1280.0] { for scale in [1.0, 1.5, 2.0] { - let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); - app.ui.dock_expanded = true; - app.ui.dock_tab = "properties".into(); + let app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); let mut ready = false; let mut harness = egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( @@ -462,7 +211,7 @@ mod visual_regressions { ready = true; return; } - show(app, ui); + crate::panel_docking::show(app, ui); }, app, ); @@ -470,10 +219,11 @@ mod visual_regressions { harness.run_steps(4); let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id()); harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); - let panel = egui::containers::panel::PanelState::load(&harness.ctx, harness.ui_id().with("dock")).unwrap().outer_rect; + let tab = crate::panel_docking::tab_id("properties"); + let tab = harness.ctx.read_response(tab).unwrap().rect; let region = serde_json::json!({ "screen": [width, 640.0], "scale": scale, - "regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]} + "regions": {"dock-tabs": [tab.left(), tab.top(), (width - tab.left()), tab.height()]} }); std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(®ion).unwrap()).unwrap(); } @@ -499,9 +249,9 @@ mod visual_regressions { let tokens = Tokens::for_brightness(brightness); crate::theme::apply(&ctx, &tokens); let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); - app.ui.dock_expanded = true; - app.ui.dock_tab = "properties".into(); - let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui)); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| crate::panel_docking::show(&mut app, ui)); + output.textures_delta.clear(); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| crate::panel_docking::show(&mut app, ui)); output.textures_delta.clear(); assert_eq!(ctx.content_rect().size(), vec2(width, 640.0)); assert_eq!(ctx.pixels_per_point(), scale); @@ -523,51 +273,3 @@ mod visual_regressions { } } } - -#[cfg(test)] -mod chevron_tests { - use super::*; - use egui_kittest::Harness; - - fn click(harness: &mut Harness<'_, DesignApp>, at: egui::Pos2) { - harness.hover_at(at); - harness.run_steps(1); - harness.drag_at(at); - harness.run_steps(1); - harness.drop_at(at); - harness.run_steps(3); - } - - /// The » / « chevron painted at the right end of a panel's 11 pt header. - fn chevron(harness: &Harness<'_, DesignApp>, panel: &str) -> egui::Pos2 { - // A panel is keyed by the parent Ui the app draws in (see `test_window::panel_rect`). - let r = egui::containers::panel::PanelState::load(&harness.ctx, harness.ui_id().with(panel)).expect("the panel is laid out").outer_rect; - egui::pos2(r.max.x - 10.0, r.min.y + 5.0) - } - - /// Issue #1203: the header chevrons were painted text; clicking them did nothing. - #[test] - fn the_dock_chevron_collapses_and_expands_the_dock() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); - app.ui.dock_expanded = true; - let mut ready = false; - let mut harness = Harness::builder().with_size(vec2(1280.0, 640.0)).build_ui_state( - move |ui, app: &mut DesignApp| { - if !ready { - crate::theme::install_fonts(ui.ctx(), "en"); - ready = true; - return; - } - show(app, ui); - }, - app, - ); - harness.run_steps(4); - let at = chevron(&harness, "dock"); - click(&mut harness, at); - assert!(!harness.state().ui.dock_expanded, "» collapses the dock to icons"); - let at = chevron(&harness, "icon_column"); - click(&mut harness, at); - assert!(harness.state().ui.dock_expanded, "« expands the dock again"); - } -} diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4c60df9b1..d1b462ab5 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -210,6 +210,96 @@ const TABLE: &[(&str, [&str; 5])] = &[ ), ("Minimize", ["Minimieren", "Réduire", "Minimizar", "最小化", "最小化"]), ("Collapse to Icons", ["Auf Symbole minimieren", "Réduire en icônes", "Contraer a iconos", "アイコンパネルに折りたたむ", "折叠为图标"]), + // Docking chrome (craft-ui labels in DesignCraft's wording, `panel_docking::DOCK_LABELS`). + ("Group with", ["Gruppieren mit", "Grouper avec", "Agrupar con", "次とグループ化", "编组到"]), + ("Panels", ["Bedienfelder", "Panneaux", "Paneles", "パネル", "面板"]), + ( + "Resize panels", + ["Größe der Bedienfelder ändern", "Redimensionner les panneaux", "Cambiar tamaño de los paneles", "パネルのサイズを変更", "调整面板大小"], + ), + ( + "Resize panel window", + [ + "Größe des Bedienfeldfensters ändern", + "Redimensionner la fenêtre de panneau", + "Cambiar tamaño de la ventana de panel", + "パネルウィンドウのサイズを変更", + "调整面板窗口大小", + ], + ), + ( + "Move tab group", + ["Registerkartengruppe verschieben", "Déplacer le groupe d'onglets", "Mover grupo de fichas", "タブグループを移動", "移动选项卡组"], + ), + ("Expand", ["Erweitern", "Développer", "Expandir", "展開", "展开"]), + ( + "Float Tab Group", + [ + "Registerkartengruppe lösen", + "Détacher le groupe d'onglets", + "Hacer flotante el grupo de fichas", + "タブグループをフローティング表示", + "浮动选项卡组", + ], + ), + ( + "Group Tab Group with", + [ + "Registerkartengruppe gruppieren mit", + "Grouper le groupe d'onglets avec", + "Agrupar grupo de fichas con", + "タブグループを次とグループ化", + "将选项卡组编组到", + ], + ), + ( + "Move panel column", + ["Bedienfeldspalte verschieben", "Déplacer la colonne de panneaux", "Mover columna de paneles", "パネル列を移動", "移动面板列"], + ), + ("Expand Panels", ["Bedienfelder erweitern", "Développer les panneaux", "Expandir paneles", "パネルを展開", "展开面板"]), + ( + "Move panel window", + ["Bedienfeldfenster verschieben", "Déplacer la fenêtre de panneau", "Mover ventana de panel", "パネルウィンドウを移動", "移动面板窗口"], + ), + ( + "Close panel window", + ["Bedienfeldfenster schließen", "Fermer la fenêtre de panneau", "Cerrar ventana de panel", "パネルウィンドウを閉じる", "关闭面板窗口"], + ), + ( + "Close Other Panels in Group", + [ + "Andere Bedienfelder der Gruppe schließen", + "Fermer les autres panneaux du groupe", + "Cerrar los demás paneles del grupo", + "グループ内の他のパネルを閉じる", + "关闭组中的其他面板", + ], + ), + ("Maximize Frame", ["Bereich maximieren", "Agrandir la zone", "Maximizar área", "領域を最大化", "最大化区域"]), + ( + "Restore Frame Size", + ["Bereichsgröße wiederherstellen", "Restaurer la taille de la zone", "Restaurar tamaño del área", "領域のサイズを元に戻す", "恢复区域大小"], + ), + ( + "Move Panel to New Window", + [ + "Bedienfeld in neues Fenster verschieben", + "Déplacer le panneau dans une nouvelle fenêtre", + "Mover panel a una ventana nueva", + "パネルを新規ウィンドウに移動", + "将面板移到新窗口", + ], + ), + ( + "Auto-Collapse Iconic Panels", + [ + "Symbolbedienfelder automatisch minimieren", + "Réduire automatiquement les panneaux d’icônes", + "Contraer automáticamente los paneles de iconos", + "アイコンパネルを自動的に折りたたむ", + "自动折叠图标面板", + ], + ), ("Panel Options", ["Bedienfeldoptionen", "Options du panneau", "Opciones de panel", "パネルオプション", "面板选项"]), ("Panel Options…", ["Bedienfeldoptionen…", "Options du panneau…", "Opciones de panel…", "パネルオプション…", "面板选项…"]), ("Show Thumbnails", ["Miniaturen anzeigen", "Afficher les vignettes", "Mostrar miniaturas", "サムネールを表示", "显示缩览图"]), @@ -1962,7 +2052,7 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Flattener Preview", ["Flattener Preview", "Aperçu de l'aplatissement", "Flattener Preview", "Flattener Preview", "拼合器预览"]), - ("Float Panel", ["Float Panel", "Détacher le panneau", "Float Panel", "Float Panel", "浮动面板"]), + ("Float Panel", ["Bedienfeld lösen", "Détacher le panneau", "Hacer flotante el panel", "パネルをフローティング表示", "浮动面板"]), ("Fonts in Document", ["Fonts in Document", "Polices du document", "Fonts in Document", "Fonts in Document", "文档中的字体"]), ("Footnotes", ["Footnotes", "Notes de bas de page", "Footnotes", "Footnotes", "脚注"]), ("Form Fields", ["Form Fields", "Champs de formulaire", "Form Fields", "Form Fields", "表单域"]), @@ -5060,6 +5150,8 @@ mod tests { keys.extend(crate::about::SIBLINGS.iter().map(|(_, _, what)| *what)); keys.extend(crate::credits::NameMode::ALL.iter().map(|mode| mode.label())); keys.extend(crate::credits::SortKey::ALL.iter().flat_map(|key| [key.label().0, key.label().1])); + // Docking chrome labels reach `tr` through craft-ui's localizer. + keys.extend(crate::panel_docking::DOCK_LABELS.iter().map(|(_, english)| *english)); // UI labels passed to `tr` that no other catalog has yet. keys.extend([ "(any)", @@ -5075,14 +5167,11 @@ mod tests { "Email Address:", "Email:", "Find fill:", - "Group with", "H:", "Message:", "Organization:", "Phone:", "W:", - "Resize panel window", - "Resize panels", "Subject:", "Text:", "Unsaved Changes", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index d19a5b56e..7a2571b5c 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -151,7 +151,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Gradient Feather Tool", "Outil Contour progressif dégradé"), ("Gradient Swatch Tool", "Outil Dégradé de nuance"), ("Graphic", "Graphique"), - ("Group with", "Grouper avec"), ("Hair Space", "Espace ultrafine"), ("Hand Tool", "Outil Main"), ("Hide Guides", "Masquer les repères"), @@ -258,7 +257,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Page Break", "Saut de page"), ("Page Options", "Options de page"), ("Page Tool", "Outil Page"), - ("Panels", "Panneaux"), ("Paragraph Return", "Retour de paragraphe"), ("Paragraph Style", "Style de paragraphe"), ("Paragraph Symbol", "Symbole de paragraphe"), @@ -303,8 +301,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Reset Bar Position", "Réinitialiser la position de la barre"), ("Resize Row or Column", "Redimensionner la ligne ou la colonne"), ("Resize Window", "Redimensionner la fenêtre"), - ("Resize panel window", "Redimensionner la fenêtre du panneau"), - ("Resize panels", "Redimensionner les panneaux"), ("Reverse the order", "Inverser l'ordre"), ("Right Indent Tab", "Tabulation de retrait à droite"), ("Rotate Tool", "Outil Rotation"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 055343fe8..95d53b798 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; pub use control::{ControlRequest, ControlResponse}; +pub use prefs::ReadPreferences; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; @@ -89,6 +90,59 @@ pub struct Services { pub clipboard_text: Option, } +/// Closed panels, each with where it goes back, in craft-ui's `Workspace::hidden` shape +/// (`[[panel, location], …]`). A floating panel's entry is where Dock Panel returns it. +/// Files from before this shape hold an object keyed by panel; it still loads. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(transparent)] +pub struct HiddenPanels(pub Vec<(String, craft_ui::docking::Location)>); + +impl<'de> Deserialize<'de> for HiddenPanels { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Shape { + List(Vec<(String, craft_ui::docking::Location)>), + Keyed(std::collections::BTreeMap>), + } + let entries = match Shape::deserialize(deserializer)? { + Shape::List(entries) => entries, + Shape::Keyed(entries) => entries.into_iter().collect(), + }; + let mut hidden = Self::default(); + for (panel, location) in entries { + hidden.insert(panel, location); + } + Ok(hidden) + } +} + +impl HiddenPanels { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn contains_key(&self, panel: &str) -> bool { + self.0.iter().any(|(p, _)| p == panel) + } + pub fn get(&self, panel: &str) -> Option<&craft_ui::docking::Location> { + self.0.iter().find(|(p, _)| p == panel).map(|(_, location)| location) + } + /// Record where `panel` goes back, replacing an earlier entry. + pub fn insert(&mut self, panel: String, location: craft_ui::docking::Location) { + self.remove(&panel); + self.0.push((panel, location)); + } + pub fn remove(&mut self, panel: &str) { + self.0.retain(|(p, _)| p != panel); + } + pub fn keys(&self) -> impl Iterator { + self.0.iter().map(|(p, _)| p) + } + pub fn clear(&mut self) { + self.0.clear(); + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ScreenMode { @@ -116,9 +170,9 @@ pub struct SavedWorkspace { /// Custom panel membership and placement; absent in older workspace files. #[serde(default, skip_serializing_if = "Option::is_none")] pub docking: Option>, - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub docking_hidden: std::collections::BTreeMap>, - /// The workspace's own icon column (see [`UiState::icon_groups`]). + #[serde(default, skip_serializing_if = "HiddenPanels::is_empty")] + pub docking_hidden: HiddenPanels, + /// The workspace's own iconic column (see [`UiState::icon_groups`]). #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, /// Saved with New Workspace › Capture › Panel Locations off: choosing the workspace leaves the @@ -183,6 +237,11 @@ impl Default for PdfExportSettings { } } +/// The viewport of New Window's OS window, which shows pane 1. +pub(crate) fn second_window_viewport() -> egui::ViewportId { + egui::ViewportId::from_hash_of("designcraft_second_window") +} + /// Persisted UI state. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -266,29 +325,44 @@ pub struct UiState { pub shortcuts: std::collections::BTreeMap, pub control_bar: bool, pub tools_double_column: bool, - /// Expanded right-dock panel group tab. + /// The front tab of the default layout's Properties · Pages · Layers group. `dock_tab`, + /// `open_panel`, `floating`, `dock_expanded` and `icon_groups` describe a workspace's + /// default layout (`panel_docking::default_layout`). pub dock_tab: String, - /// Panel opened from the collapsed icon column (flyout). + /// The panel it opens below that group (Printing and Proofing: Swatches). pub open_panel: Option, - /// Panels torn off the dock into their own floating windows: (panel id, top-left position). + /// Its floating panels, and the float-only panels' windows (Type › Tabs): (panel id, + /// top-left position). A customized layout holds its own floating panels. pub floating: Vec<(String, [f32; 2])>, - /// Shared panel layout after the user customizes the legacy dock. + /// The panel layout once the user customizes it; `None` is the workspace's default layout + /// (`panel_docking::default_layout`). #[serde(default, skip_serializing_if = "Option::is_none")] pub docking: Option>, - #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub docking_hidden: std::collections::BTreeMap>, + #[serde(default, skip_serializing_if = "HiddenPanels::is_empty")] + pub docking_hidden: HiddenPanels, + /// The panel (or the canvas) last clicked or selected in the docking layout, while the panel + /// stays where the user left it. + #[serde(skip)] + pub docking_focused: Option, /// Invalidates in-flight gestures when a workspace is replaced. #[serde(skip)] pub docking_generation: u64, /// One-frame reveal request; z-order is never persisted. #[serde(skip)] pub docking_raise: Option, - /// Last explicitly revealed customized panel, for the legacy closePanel control. + /// The panel last revealed explicitly, which `ui.set closePanel` closes. #[serde(skip)] pub docking_opened: Option, + /// The panel whose iconic-column flyout is open, as of the last frame or a command that + /// opened it. + #[serde(skip)] + pub docking_flyout: Option, + /// Auto-Collapse Iconic Panels: a flyout closes on Escape or a click outside it. + pub dock_auto_collapse: bool, + /// Whether the default layout's outer column is expanded (false: collapsed to icons). pub dock_expanded: bool, - /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the icon - /// column instead of every panel; `None` is the full icon column. + /// A workspace's iconic panel groups (Essentials Classic), shown with their names in the + /// iconic column instead of every panel; `None` is one icon per panel. #[serde(default, skip_serializing_if = "Option::is_none")] pub icon_groups: Option>>, pub units: Unit, @@ -426,6 +500,9 @@ impl Default for UiState { docking_generation: 0, docking_raise: None, docking_opened: None, + docking_flyout: None, + dock_auto_collapse: true, + docking_focused: None, dock_expanded: true, icon_groups: None, units: Unit::Picas, @@ -1597,6 +1674,18 @@ impl DesignApp { self.open_dropped_at(path, None) } + /// Open or place each file dropped on a floating panel's own window. `pointer` is in the main + /// window's coordinates. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn open_dropped_files(&mut self, files: &[egui::DroppedFileHandle], pointer: Option) { + for f in files { + let p = f.path().to_string_lossy().to_string(); + if !p.is_empty() { + let _ = self.open_dropped_at(&p, pointer); + } + } + } + /// [`Self::open_dropped`] at the pointer: a graphic's top left lands where the file was /// dropped on the canvas, text goes to a new frame on the document page there. pub fn open_dropped_at(&mut self, path: &str, pointer: Option) -> Result { @@ -1880,6 +1969,63 @@ impl DesignApp { self.control_input_frame.store(false, std::sync::atomic::Ordering::Relaxed); } + /// The document area: the start screen, or the document tabs over one or two canvas panes. + pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui, presenting: bool) { + let t = theme::Tokens::get(ui.ctx()); + if self.session.active().is_none() { + chrome::start_screen(self, ui); + return; + } + if !presenting { + chrome::doc_tabs(self, ui); + } + let split_layout = (self.split && !presenting) + .then(|| { + craft_ui::layout::split_rect( + ui.available_rect_before_wrap(), + craft_ui::layout::SplitAxis::Horizontal, + craft_ui::layout::SplitSize::Ratio(0.5), + 2.0, + 0.0, + ) + }) + .flatten(); + if let Some(layout) = split_layout { + // Two panes with a divider; each has its own view (zoom, scroll) and render cache. + ui.painter().rect_filled(layout.divider, 0.0, t.border); + // Resolve the clicked pane before either canvas handles this frame's keys. + if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { + if layout.first.contains(p) { + self.focus_pane = 0; + } else if layout.second.contains(p) { + self.focus_pane = 1; + } + } + for (k, half) in [layout.first, layout.second].into_iter().enumerate() { + self.switch_pane(k as u8); + // A new pane starts at the other pane's view. + if k == 1 && self.view().is_none() { + self.switch_pane(0); + let v = self.view().copied(); + self.switch_pane(1); + if let (Some(mut v), Some(m)) = (v, self.view_mut()) { + v.fitted = false; + *m = v; + } + } + ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); + } + self.switch_pane(self.focus_pane); + } else { + self.switch_pane(0); + // With a second window, the main one takes the focus back when clicked. + if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) { + self.focus_pane = 0; + } + canvas::show(self, ui); + } + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(test)] @@ -1924,7 +2070,7 @@ impl DesignApp { let t = theme::Tokens::get(&ctx); let presenting = self.ui.screen_mode == ScreenMode::Presentation; if presenting || self.ui.hidden_panels != 0 { - panel_docking::cancel_gesture(self, &ctx); + panel_docking::cancel_gesture(&ctx); } canvas::begin_frame(&ctx); let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some(); @@ -1946,62 +2092,13 @@ impl DesignApp { if self.ui.hidden_panels != 1 { toolbar::show(self, ui); } - if self.ui.hidden_panels == 0 { - dock::show(self, ui); - } } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.session.active().is_none() { - chrome::start_screen(self, ui); - return; - } - if !presenting { - chrome::doc_tabs(self, ui); - } - let split_layout = (self.split && !presenting) - .then(|| { - craft_ui::layout::split_rect( - ui.available_rect_before_wrap(), - craft_ui::layout::SplitAxis::Horizontal, - craft_ui::layout::SplitSize::Ratio(0.5), - 2.0, - 0.0, - ) - }) - .flatten(); - if let Some(layout) = split_layout { - // Two panes with a divider; each has its own view (zoom, scroll) and render cache. - ui.painter().rect_filled(layout.divider, 0.0, t.border); - // Resolve the clicked pane before either canvas handles this frame's keys. - if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { - if layout.first.contains(p) { - self.focus_pane = 0; - } else if layout.second.contains(p) { - self.focus_pane = 1; - } - } - for (k, half) in [layout.first, layout.second].into_iter().enumerate() { - self.switch_pane(k as u8); - // A new pane starts at the other pane's view. - if k == 1 && self.view().is_none() { - self.switch_pane(0); - let v = self.view().copied(); - self.switch_pane(1); - if let (Some(mut v), Some(m)) = (v, self.view_mut()) { - v.fitted = false; - *m = v; - } - } - ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); - } - self.switch_pane(self.focus_pane); + // The panel layout holds the canvas as its protected `canvas` panel. + if presenting || self.ui.hidden_panels != 0 { + self.canvas_area(ui, presenting); } else { - self.switch_pane(0); - // With a second window, the main one takes the focus back when clicked. - if !self.second_window || (ui.input(|i| i.pointer.any_pressed()) && ui.ui_contains_pointer()) { - self.focus_pane = 0; - } - canvas::show(self, ui); + panel_docking::show(self, ui); } }); // New Window: pane 1 in a window of its own. @@ -2012,7 +2109,7 @@ impl DesignApp { let fill = t.pasteboard; let ctx = ui.ctx().clone(); ctx.show_viewport_immediate( - egui::ViewportId::from_hash_of("designcraft_second_window"), + second_window_viewport(), egui::ViewportBuilder::default().with_title(title).with_inner_size([960.0, 720.0]), |ui, _class| { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| { @@ -2047,8 +2144,7 @@ impl DesignApp { } // Tab, Shift+Tab and Presentation hide the floating panels with the docked ones. if !presenting && self.ui.hidden_panels == 0 { - dock::flyout(self, &ctx); - dock::floating(self, &ctx); + dock::float_only_windows(self, &ctx); } story_editor::show(self, &ctx); dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b171215b9..fda15c6bb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -264,7 +264,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ None, "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table|preflight}", ), - ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), + ("window.floatPanel", "Float Panel", None, "{panel, x?, y?, width?, height?} — the panel in its own floating window (window.panel.float)"), ( "window.panel.layout", "Change Panel Layout", @@ -275,15 +275,45 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "window.panel.move", "Move Panel", None, - "{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel", + "{panel, anchor, zone?: tab|center|left|right|top|bottom|above|below, before?: panel|null, group?, placement?} — group, split or reorder a panel (group: its whole tab group)", + ), + ( + "window.panel.float", + "Float Panel", + None, + "{panel, x?, y?, width?, height?, group?, column?} — detach a panel (or its group, or its dock column) into a floating window", + ), + ( + "window.panel.dock", + "Dock Panel", + None, + "{panel, anchor?, zone?, before?, group?, column?, side?: Start|End} — dock a floating panel: beside anchor, in a new column at side, or back where it floated from", ), - ("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"), - ("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"), ("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"), ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.panel.closeGroup", "Close Tab Group", None, "{panel} — hide the panel and the panels tabbed with it"), + ("window.panel.closeOthers", "Close Other Panels in Group", None, "{panel} — hide the panels tabbed with it"), + ("window.panel.minimize", "Minimize", None, "{panel, minimized?} — minimize the panel's group to its tab strip (toggles without minimized)"), + ("window.panel.iconic", "Collapse to Icons", None, "{panel, iconic?} — collapse the panel's dock column to icons (toggles without iconic)"), + ( + "window.panel.window", + "Move Panel to New Window", + None, + "{panel, native?} — not available: a floating panel has its own window on the desktop and stays in the page on the web", + ), + ("window.workspace.new", "New Workspace", None, "{name} — save the current workspace (window.newWorkspace)"), + ("window.workspace.delete", "Delete Workspace", None, "{name} — delete a saved workspace (window.deleteWorkspace)"), + ("window.workspace.reset", "Reset Workspace", None, "{} — back to the current workspace as saved (window.resetWorkspace)"), + ("window.workspace.lock", "Lock Workspace", None, "{locked?} — not available in DesignCraft"), + ( + "ui.dock", + "Change Panel Layout", + None, + "{action} | {operation: open|close|activate|float|moveFloating|move|setStackOpen|resizeStack|resizeSplit, …} — craft-ui's shared ui.dock shape", + ), + ("ui.dock.reset", "Reset Workspace", None, "{} — window.workspace.reset"), ("window.collapseToIcons", "Collapse to Icons", None, "{} — the panel dock shrinks to its column of icons"), - ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), + ("window.dockPanel", "Dock Panel", None, "{panel} — back to its place in the dock (window.panel.dock)"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), // Object › Fitting with InDesign's shortcuts (one `object.fit` mode each). ("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"), @@ -969,7 +999,16 @@ pub const MENUS: &[(&str, &[&str])] = &[ pub const WORKSPACES: &[&str] = &["Advanced", "Book", "Digital Publishing", "Essentials", "Essentials Classic", "Interactive for PDF", "Printing and Proofing", "Typography"]; -/// Essentials Classic's icon column: InDesign's iconic panel groups, top to bottom. +/// The built-in workspaces that open a panel below the Properties · Pages · Layers group. +pub const WORKSPACE_OPEN_PANELS: &[(&str, &str)] = + &[("Printing and Proofing", "swatches"), ("Interactive for PDF", "buttons"), ("Digital Publishing", "liquid")]; + +/// The panel the built-in workspace `name` opens below the Properties · Pages · Layers group. +pub fn workspace_open_panel(name: &str) -> Option<&'static str> { + WORKSPACE_OPEN_PANELS.iter().find(|(workspace, _)| *workspace == name).map(|(_, panel)| *panel) +} + +/// Essentials Classic's iconic column: InDesign's iconic panel groups, top to bottom. pub const CLASSIC_GROUPS: &[&[&str]] = &[ &["pages", "layers", "links"], &["stroke", "color"], @@ -1000,10 +1039,10 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Type › Tabs: float the Tabs panel (placed above the selected text frame) or close it. fn toggle_tabs_panel(app: &mut DesignApp) { if app.ui.floating.iter().any(|(p, _)| p == "tabs") { - crate::dock::dock_panel(app, "tabs"); + app.ui.floating.retain(|(p, _)| p != "tabs"); } else { let at = app.canvas_rect.map_or(egui::pos2(300.0, 140.0), |r| r.min + egui::vec2(80.0, 60.0)); - crate::dock::float_panel(app, "tabs", at); + app.ui.floating.push(("tabs".into(), [at.x, at.y])); app.ui.tabs_panel.snap_request = 2; } } @@ -1399,10 +1438,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - app.ui.open_panel = if app.ui.open_panel.as_deref() == Some("table") { None } else { Some("table".into()) }; - Ok(Value::Null) - } "app.tabsPanel" => { toggle_tabs_panel(app); Ok(Value::Null) @@ -1715,19 +1750,13 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option app.run("text.insert", json!({"text": "\u{e000}", "raw": true})), + // `panel_docking::command` takes the dock panels (and the Window › Styles group) first. "window.panel" => { - let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties").to_string(); - // A panel in the workspace's icon groups opens from there (Essentials Classic's Pages). - let iconic = app.ui.icon_groups.as_ref().is_some_and(|g| g.iter().flatten().any(|p| *p == panel)); - if panel == "tabs" { - toggle_tabs_panel(app); - } else if !iconic && crate::dock::DOCK_TABS.iter().any(|(id, _, _)| *id == panel) { - app.ui.dock_tab = panel; - app.ui.dock_expanded = true; - app.ui.open_panel = None; - } else if !app.ui.floating.iter().any(|(p, _)| *p == panel) { - app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel.as_str()) { None } else { Some(panel) }; + let panel = p.get("panel").and_then(Value::as_str).unwrap_or("properties"); + if panel != "tabs" { + return crate::panel_docking::command(app, "window.panel.activate", &json!({"panel": panel})); } + toggle_tabs_panel(app); Ok(Value::Null) } "fit.fillFrameProportionally" | "fit.contentProportionally" | "fit.frameToContent" | "fit.contentToFrame" | "fit.centerContent" => { @@ -1816,22 +1845,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { - let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); - if !crate::dock::ICON_PANELS.iter().any(|(id, _, _)| *id == panel) && !crate::dock::FLOATING_PANELS.iter().any(|(id, _)| *id == panel) { - return Some(Err(format!("{id}: unknown panel `{panel}`"))); - } - if id == "window.floatPanel" { - let at = egui::pos2( - p.get("x").and_then(Value::as_f64).unwrap_or(400.0) as f32, - p.get("y").and_then(Value::as_f64).unwrap_or(160.0) as f32, - ); - crate::dock::float_panel(app, panel, at); - } else { - crate::dock::dock_panel(app, panel); - } - Ok(json!({"floating": app.ui.floating.iter().map(|(p, _)| p.clone()).collect::>()})) - } "window.controlBar" => flag(&mut app.ui.control_bar), "view.flattenerPreview" => flag(&mut app.ui.flattener_preview), "view.tagMarkers" => { @@ -1912,11 +1925,6 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.tools_double_column), - "window.collapseToIcons" => { - app.ui.dock_expanded = false; - app.ui.open_panel = None; - Ok(Value::Null) - } "window.newWorkspace" => { let Some(name) = p.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()) else { app.ui.dialog = @@ -1963,8 +1971,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option { app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1); - app.ui.docking_raise = None; - app.ui.docking_opened = None; + crate::panel_docking::forget_panels(app); let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials"); if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() { if let Some(hidden) = w.hidden_menu_items { @@ -1995,7 +2002,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some(CLASSIC_GROUPS), @@ -2012,12 +2019,7 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option Some("swatches".into()), - "Interactive for PDF" => Some("buttons".into()), - "Digital Publishing" => Some("liquid".into()), - _ => None, - }; + app.ui.open_panel = workspace_open_panel(name).map(str::to_string); app.ui.workspace = name.to_string(); Ok(Value::Null) } @@ -2226,6 +2228,10 @@ pub fn enabled(app: &DesignApp, id: &str) -> bool { if is_window_control(id) { return window_control_available(app, id); } + // Shared docking commands DesignCraft does not provide (native panel windows, Lock Workspace). + if matches!(id, "window.panel.window" | "window.workspace.lock") { + return false; + } match designcraft_engine::find_command(id) { Some(c) => (c.enabled)(&app.session).is_ok(), None => true, @@ -3195,93 +3201,149 @@ pub fn paste_chord_command(app: &DesignApp, modifiers: egui::Modifiers) -> Optio pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { // Only a focused text field takes the keys: the canvas (or a button) having focus after a click // must not swallow tool shortcuts until Esc clears it. - // While an IME composes at the text caret, the keyboard is its own. An open canvas context menu - // takes the keys (Escape closes it), as a native menu would. - if ctx.text_edit_focused() - || app.ui.dialog.is_some() - || app.ui.palette.is_some() - || app.session.tool_composing() - || crate::canvas::context_menu_open(ctx) - || crate::chrome::search_had_focus(ctx) - { + if ctx.text_edit_focused() || keys_taken(app, ctx) { return; } let typing = app.session.wants_text(); let events = ctx.input(|i| i.events.clone()); for e in events { let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e else { continue }; - // Presentation hides every control, so Esc always leaves it, and only that: the canvas must - // not also see the press and switch tools or deselect. - if key == egui::Key::Escape && app.ui.screen_mode == ScreenMode::Presentation { + if !shortcut_key(app, key, modifiers, typing, &mut || { ctx.input_mut(|i| i.consume_key(modifiers, key)); - let _ = app.run("view.togglePresentation", json!({})); - continue; + }) { + escape_to_selection(app, key, modifiers, typing); } - // Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End. - if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt { - let step = match key { - egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1), - egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1), - egui::Key::Home => Some(isize::MIN), - egui::Key::End => Some(isize::MAX), - _ => None, - }; - if let Some(step) = step { - ctx.input_mut(|i| i.consume_key(modifiers, key)); - crate::canvas::step_spread(app, step); + } +} + +/// Keys that a floating panel in its own OS window left over (craft-ui's +/// `DockOutput::forwarded_events`, with the modifiers held in that window): they run the main +/// window's shortcuts, clipboard and tool keys in the frame they arrive, so the keyboard works +/// while a floating window has focus. craft-ui forwards nothing while one of that window's +/// widgets has keyboard focus. +pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec, modifiers: egui::Modifiers) { + if events.is_empty() || keys_taken(app, ctx) { + return; + } + let typing = app.session.wants_text(); + let mut rest = Vec::with_capacity(events.len()); + let mut escape = None; + for e in events { + if let egui::Event::Key { key, pressed: true, modifiers, repeat: false, .. } = e { + if shortcut_key(app, key, modifiers, typing, &mut || {}) { continue; } - } - // Command shortcuts. - let mut fired: Option<(String, bool)> = None; - let all = effective_shortcuts(app); - for (id, sc) in &all { - let id = id.as_str(); - if let Some((m, k)) = parse_shortcut(sc) - && let Some(exact) = key_matches(m, k, key, modifiers) - && (m.command || m.alt || !typing) - && !(id == "edit.clear" && typing) - { - // Single-key shortcuts (W) only when not typing. - if !m.command && !m.alt && !m.shift && typing { - continue; - } - if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) { - continue; // Handled by the active tool / text clipboard events. - } - fired = Some((id.to_string(), exact)); - break; + if key == egui::Key::Escape { + escape = Some(modifiers); } } - if let Some((id, exact)) = fired { - // The native menu bar carries the shortcuts in effect, the user's included, and its - // accelerator fires only for the shortcut's own key and modifiers; the Plus key standing - // in for = never reaches it. - if exact && app.native_shortcuts.contains(&id) { - continue; // The native menu handles it. - } - // The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's - // default button) must not see the same press as Enter or Space this frame. - ctx.input_mut(|i| i.consume_key(modifiers, key)); - // Like choosing the menu item: "…" commands open their dialog. - activate(app, &id, &Value::Null); - continue; + rest.push(e); + } + crate::canvas::forwarded_keys(app, rest, modifiers); + if let Some(m) = escape { + escape_to_selection(app, egui::Key::Escape, m, typing); + } +} + +/// Whether something other than the shortcuts has the keyboard: a dialog or Quick Apply, an input +/// method composing at the text caret, an open canvas context menu (Escape closes it, as a +/// native menu would) or the menu search. +fn keys_taken(app: &DesignApp, ctx: &egui::Context) -> bool { + app.ui.dialog.is_some() + || app.ui.palette.is_some() + || app.session.tool_composing() + || crate::canvas::context_menu_open(ctx) + || crate::chrome::search_had_focus(ctx) +} + +/// Runs the presentation key, command shortcut or tool shortcut a key press stands for. `consume` +/// takes the press from the input where nothing else may see it. Returns whether the press was used. +fn shortcut_key(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool, consume: &mut dyn FnMut()) -> bool { + // Presentation hides every control, so Esc always leaves it, and only that: the canvas must + // not also see the press and switch tools or deselect. + if key == egui::Key::Escape && app.ui.screen_mode == ScreenMode::Presentation { + consume(); + let _ = app.run("view.togglePresentation", json!({})); + return true; + } + // Presentation goes through the spreads with the arrow keys, Page Up/Down, Home and End. + if app.ui.screen_mode == ScreenMode::Presentation && !modifiers.command && !modifiers.alt { + let step = match key { + egui::Key::ArrowRight | egui::Key::ArrowDown | egui::Key::PageDown => Some(1), + egui::Key::ArrowLeft | egui::Key::ArrowUp | egui::Key::PageUp => Some(-1), + egui::Key::Home => Some(isize::MIN), + egui::Key::End => Some(isize::MAX), + _ => None, + }; + if let Some(step) = step { + consume(); + crate::canvas::step_spread(app, step); + return true; } - // Tool shortcuts (single keys, Shift+key). - if !typing && !modifiers.command && !modifiers.alt { - let name = key.name(); - let sc = if modifiers.shift { format!("Shift+{name}") } else { name.to_string() }; - if let Some(tool) = designcraft_tools::tool_for_shortcut(&sc) { - log::debug!("tool shortcut {sc} -> {tool}"); - if std::env::var_os("DESIGNCRAFT_DEBUG_KEYS").is_some() { - eprintln!("tool shortcut {sc} ({key:?}, {modifiers:?}) -> {tool}"); - } - app.select_tool(tool); - } else if key == egui::Key::Escape && app.session.tool_id() != "selection" && !app.session.tool_busy() && app.ui.flyout.is_none() { - app.select_tool("selection"); + } + // Command shortcuts. + let mut fired: Option<(String, bool)> = None; + let all = effective_shortcuts(app); + for (id, sc) in &all { + let id = id.as_str(); + if let Some((m, k)) = parse_shortcut(sc) + && let Some(exact) = key_matches(m, k, key, modifiers) + && (m.command || m.alt || !typing) + && !(id == "edit.clear" && typing) + { + // Single-key shortcuts (W) only when not typing. + if !m.command && !m.alt && !m.shift && typing { + continue; } + if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) { + continue; // Handled by the active tool / text clipboard events. + } + fired = Some((id.to_string(), exact)); + break; } } + if let Some((id, exact)) = fired { + // The native menu bar carries the shortcuts in effect, the user's included, and its + // accelerator fires only for the shortcut's own key and modifiers; the Plus key standing + // in for = never reaches it. + if exact && app.native_shortcuts.contains(&id) { + return true; // The native menu handles it. + } + // The key press belongs to the shortcut: what it opens (Quick Apply's list, a dialog's + // default button) must not see the same press as Enter or Space this frame. + consume(); + // Like choosing the menu item: "…" commands open their dialog. + activate(app, &id, &Value::Null); + return true; + } + // Tool shortcuts (single keys, Shift+key). + if !typing && !modifiers.command && !modifiers.alt { + let name = key.name(); + let sc = if modifiers.shift { format!("Shift+{name}") } else { name.to_string() }; + if let Some(tool) = designcraft_tools::tool_for_shortcut(&sc) { + log::debug!("tool shortcut {sc} -> {tool}"); + if std::env::var_os("DESIGNCRAFT_DEBUG_KEYS").is_some() { + eprintln!("tool shortcut {sc} ({key:?}, {modifiers:?}) -> {tool}"); + } + app.select_tool(tool); + return true; + } + } + false +} + +/// Esc with no shortcut of its own goes back to the Selection tool. +fn escape_to_selection(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool) { + if key == egui::Key::Escape + && !typing + && !modifiers.command + && !modifiers.alt + && app.session.tool_id() != "selection" + && !app.session.tool_busy() + && app.ui.flyout.is_none() + { + app.select_tool("selection"); + } } /// Quick Apply (⌘Return): the document's styles and every command, searched by name. @@ -3422,6 +3484,19 @@ const NOT_SEARCHED: &[&str] = &[ "view.zoom", // Where to pin the Contextual Task Bar. "window.taskBarPin", + // Shared docking commands DesignCraft doesn't provide, those that need a panel or a name, + // and older ids that share a label with a searched docking command. + "window.panel", + "window.panel.window", + "window.workspace.lock", + "window.panel.closeOthers", + "window.panel.minimize", + "window.panel.iconic", + "window.workspace.new", + "window.workspace.delete", + "ui.dock", + "ui.dock.reset", + "window.resetWorkspace", ]; /// The commands Quick Apply and the application bar's search find: (id, label, default shortcut). @@ -4107,9 +4182,10 @@ mod tests { out.textures_delta.clear(); } }; - // The icon column's flyout (the classic dock) and a floating panel group (the docking layout). - let flyout = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("panel_flyout")); - let group = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("designcraft-panel-docking").with(("floating", "preflight"))); + // The docked panel's tab and a floating panel group. + let tab = crate::panel_docking::tab_id("preflight"); + let docked = || ctx.read_response(tab).is_some(); + let group = crate::panel_docking::floating_layer("preflight"); let shown = |layer: egui::LayerId| ctx.memory(|m| m.areas().is_visible(&layer)); let toggles = [ ("window.hidePanels", json!({}), json!({})), @@ -4118,14 +4194,14 @@ mod tests { ]; app.run("window.panel", json!({"panel": "preflight"})).unwrap(); frame(&mut app); - assert!(app.ui.docking.is_none() && shown(flyout)); + assert!(docked()); for (id, hide, show) in &toggles { app.run(id, hide.clone()).unwrap(); frame(&mut app); - assert!(!shown(flyout), "{id} hides the flyout"); + assert!(!docked(), "{id} hides the dock"); app.run(id, show.clone()).unwrap(); frame(&mut app); - assert!(shown(flyout), "{id} again brings it back"); + assert!(docked(), "{id} again brings it back"); } app.run("window.floatPanel", json!({"panel": "preflight", "x": 400, "y": 200})).unwrap(); frame(&mut app); @@ -5117,13 +5193,21 @@ mod tests { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "swatches", "x": 50, "y": 60})).unwrap().unwrap(); assert_eq!(r["floating"], json!(["swatches"])); - assert_eq!(app.ui.floating[0].1, [50.0, 60.0]); - // Showing a floating panel doesn't also open its flyout. + let window = &app.ui.docking.as_ref().unwrap().floating[0]; + assert_eq!([window.rect[0], window.rect[1]], [50.0, 60.0]); + // Showing a floating panel leaves it where it is. run_ui(&mut app, "window.panel", &json!({"panel": "swatches"})).unwrap().unwrap(); assert_eq!(app.ui.open_panel, None); - run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap(); - assert!(app.ui.floating.is_empty()); + let r = run_ui(&mut app, "window.dockPanel", &json!({"panel": "swatches"})).unwrap().unwrap(); + assert_eq!(r["floating"], json!([])); + assert!(app.ui.docking.as_ref().unwrap().floating.is_empty()); assert!(run_ui(&mut app, "window.floatPanel", &json!({"panel": "nope"})).unwrap().is_err()); + // Float-only panels (Type › Tabs) have their own windows outside the layout. + let r = run_ui(&mut app, "window.floatPanel", &json!({"panel": "tabs", "x": 10, "y": 20})).unwrap().unwrap(); + assert_eq!(r["floating"], json!(["tabs"])); + assert_eq!(app.ui.floating, [("tabs".to_string(), [10.0, 20.0])]); + run_ui(&mut app, "window.dockPanel", &json!({"panel": "tabs"})).unwrap().unwrap(); + assert!(app.ui.floating.is_empty()); } /// One frame as the desktop app runs it: queued synthetic input, then the native menu item @@ -5369,8 +5453,17 @@ mod tests { assert!(!quick.iter().any(|i| i.id == "window.deleteWorkspace")); // Each command runs as Quick Apply runs an entry. One that needs a selection may fail // without it, but runs in one of the contexts; none changes a preference it wasn't asked to. - // Opening a panel changes where the panels are, as it should. - let opens_a_panel = ["app.tabsPanel", "app.characterStylesPanel", "app.paragraphStylesPanel"]; + // Opening a panel or collapsing the dock to icons changes where the panels are, as it should. + let opens_a_panel = [ + "app.tabsPanel", + "app.characterStylesPanel", + "app.paragraphStylesPanel", + "app.characterPanel", + "app.paragraphPanel", + "app.glyphsPanel", + "app.tablePanel", + "window.collapseToIcons", + ]; let arrangement = ["docking", "dockingHidden", "floating"]; let mut failed = Vec::new(); for (id, ..) in quick_commands().filter(|(id, ..)| UI_COMMANDS.iter().any(|c| c.0 == *id)) { @@ -5538,11 +5631,17 @@ mod tests { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); activate(&mut app, "app.characterPanel", &Value::Null); - assert_eq!(app.ui.open_panel.as_deref(), Some("character")); + assert!(crate::panel_docking::showing(&app, "character")); // Paragraph Styles opens floating, grouped with Character Styles, as in InDesign. activate(&mut app, "app.paragraphStylesPanel", &Value::Null); let layout = app.ui.docking.as_ref().unwrap(); - assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1)); + assert!( + layout + .floating + .iter() + .flat_map(crate::panel_docking::window_groups) + .any(|g| g.panels == ["characterStyles", "paragraphStyles"] && g.active == 1) + ); let story = app.run("frame.create", json!({"rect": [36, 36, 200, 100], "content": "text", "text": "Page "})).unwrap()["story"].clone(); app.run("text.select", json!({"story": story, "anchor": 5, "focus": 5})).unwrap(); activate(&mut app, "app.insertCurrentPageNumber", &Value::Null); @@ -5615,7 +5714,10 @@ mod tests { assert_eq!(checked(&app, "window.workspace", &json!({"name": "Essentials"})), Some(false)); // Window › Pages opens Pages from its icon group, not the Properties stack. run_ui(&mut app, "window.panel", &json!({"panel": "pages"})).unwrap().unwrap(); - assert_eq!((app.ui.open_panel.as_deref(), app.ui.dock_expanded), (Some("pages"), false)); + assert!(crate::panel_docking::showing(&app, "pages")); + let layout = app.ui.docking.clone().unwrap(); + assert!(!layout.contains(&"properties".to_string())); + assert_eq!(crate::panel_docking::tab_group(&layout, "pages"), ["pages", "layers", "links"]); // A workspace saved from it keeps the groups. run_ui(&mut app, "window.newWorkspace", &json!({"name": "Mine"})).unwrap().unwrap(); run_ui(&mut app, "window.workspace", &json!({"name": "Essentials"})).unwrap().unwrap(); @@ -5637,8 +5739,8 @@ mod tests { assert_eq!(groups[5], ["character", "characterStyles"]); for panel in ["pages", "paragraphStyles", "characterStyles"] { run_ui(&mut app, "window.panel", &json!({"panel":panel})).unwrap().unwrap(); - assert_eq!(app.ui.open_panel.as_deref(), Some(panel)); - assert!(!app.ui.dock_expanded); + assert!(crate::panel_docking::showing(&app, panel), "{panel}"); + assert!(app.ui.docking.as_ref().unwrap().floating.is_empty(), "{panel} opens from its icon group"); } run_ui(&mut app, "window.resetWorkspace", &json!({})).unwrap().unwrap(); assert_eq!(app.ui.icon_groups, Some(groups)); @@ -6268,4 +6370,61 @@ mod tests { } assert!(app.ui.dialog.is_some(), "clicking the empty end of the Preferences… row must choose it"); } + + /// Quick Apply and the menu search list no unavailable docking command and no label twice + /// among the UI commands. + #[test] + fn searched_ui_commands_are_available_and_named_once() { + let ui_ids: Vec<&str> = UI_COMMANDS.iter().map(|c| c.0).collect(); + let searched: Vec<(&str, &str)> = quick_commands().filter(|c| ui_ids.contains(&c.0)).map(|c| (c.0, c.1)).collect(); + assert!(!searched.iter().any(|c| matches!(c.0, "window.panel.window" | "window.workspace.lock" | "window.workspace")), "{searched:?}"); + assert!(searched.iter().any(|c| c.0 == "window.collapseToIcons"), "Collapse to Icons is searched"); + // Quick Apply runs an entry as `app.run(id, {})`, so each docking command it lists must + // work without params. + let docking = |id: &str| { + id.starts_with("window.panel") + || id.starts_with("window.workspace") + || id.starts_with("ui.dock") + || matches!(id, "window.floatPanel" | "window.dockPanel" | "window.collapseToIcons" | "window.resetWorkspace") + }; + let failing: Vec<(&str, String)> = searched + .iter() + .filter(|c| docking(c.0)) + .filter_map(|c| { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run(c.0, json!({})).err().map(|e| (c.0, e)) + }) + .collect(); + assert!(failing.is_empty(), "searched docking commands that fail with {{}}: {failing:?}"); + let mut labels: Vec<&str> = searched.iter().map(|c| c.1).collect(); + labels.sort_unstable(); + let before = labels.len(); + labels.dedup(); + assert_eq!(labels.len(), before, "a label twice: {searched:?}"); + } + + /// Keys a floating panel's own OS window left over run the main window's tool shortcuts, + /// Escape and the active tool's keys. + #[test] + fn forwarded_keys_run_shortcuts() { + use egui::{Event, Key, Modifiers}; + fn press(key: Key) -> Vec { + [true, false].map(|pressed| Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers: Modifiers::NONE }).to_vec() + } + let ctx = egui::Context::default(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let frame = app.session.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap(); + app.session.execute("selection.set", &json!({"ids": [frame["id"]]})).unwrap(); + forwarded_shortcuts(&mut app, &ctx, press(Key::P), Modifiers::NONE); + assert_ne!(app.session.tool_id(), "selection", "P picks a tool"); + forwarded_shortcuts(&mut app, &ctx, press(Key::Escape), Modifiers::NONE); + assert_eq!(app.session.tool_id(), "selection", "Escape goes back to the Selection tool"); + app.session.execute("selection.set", &json!({"ids": [frame["id"]]})).unwrap(); + forwarded_shortcuts(&mut app, &ctx, press(Key::Delete), Modifiers::NONE); + assert!( + app.session.active().unwrap().doc.item(designcraft_doc::ItemId(frame["id"].as_u64().unwrap())).is_none(), + "Delete clears the selection" + ); + } } diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index b022f2647..6c48614e2 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -1,38 +1,54 @@ //! Application-owned panel IDs and commands around the shared docking layout. +//! +//! The window's panels and the canvas are one craft-ui layout: a horizontal split of the +//! protected `canvas` panel and a dock at the logical end edge (the right in left-to-right +//! languages, the left in right-to-left ones). -use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone}; +use craft_ui::docking::{ + Action, Column, DockCommand, DockOperation, FloatSpace, Floating, Group, Layout, Node, Permissions, Placement, Side, Target, UiDock, Workspace, +}; use serde_json::{Value, json}; use crate::DesignApp; -#[derive(Clone, Default)] -struct LegacyDrag { - layout: Option>, - open_panel: Option, - floating: Vec<(String, [f32; 2])>, - generation: u64, +/// The id of the dock area that draws the panels. +fn area_id() -> egui::Id { + egui::Id::unique("designcraft-panel-docking") } -pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) { - cancel_legacy_drag(app, ctx); - craft_ui::docking::cancel_drag::(ctx, egui::Id::unique("designcraft-panel-docking")); +/// The dock area a headless app floats into, before it ever drew a frame. +const HEADLESS_AREA: egui::Rect = egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1400.0, 900.0)); + +/// The egui id of a docking control, for tests that press it or read its response. +#[cfg(test)] +pub(crate) fn hit_id(hit: &craft_ui::docking::Hit) -> egui::Id { + craft_ui::docking::DockArea::::hit_id(area_id(), hit) } -fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool { - let key = egui::Id::unique("designcraft-panel-docking").with("legacy-origin"); - let previous = ctx.data_mut(|data| data.remove_temp::(key)); - if let Some(previous) = previous { - craft_ui::docking::cancel_drag::(ctx, egui::Id::unique("designcraft-panel-docking")); - if previous.generation != app.ui.docking_generation { - return false; - } - app.ui.open_panel = previous.open_panel; - app.ui.floating = previous.floating; - app.ui.docking = previous.layout; - true - } else { - false +/// The egui id of a panel's tab. +#[cfg(test)] +pub(crate) fn tab_id(panel: &str) -> egui::Id { + hit_id(&craft_ui::docking::Hit::Tab(panel.to_owned())) +} + +/// The viewports of the floating windows that may have OS windows of their own. Where egui +/// embeds viewports every floating window stays in the main window. +pub(crate) fn native_viewports(app: &DesignApp, ctx: &egui::Context) -> Vec { + if ctx.embed_viewports() { + return Vec::new(); } + layout_of(app).floating.iter().map(|window| craft_ui::docking::DockArea::::window_viewport(area_id(), window.id)).collect() +} + +/// The egui layer of the floating window whose first panel is `panel`. +#[cfg(test)] +pub(crate) fn floating_layer(panel: &str) -> egui::LayerId { + let window = Floating::single(vec![panel.to_owned()], 0, [0.0, 0.0, 1.0, 1.0]); + craft_ui::docking::DockArea::::floating_layer(area_id(), &window).unwrap_or_else(egui::LayerId::background) +} + +pub(crate) fn cancel_gesture(ctx: &egui::Context) { + craft_ui::docking::cancel_drag::(ctx, area_id()); } fn normalize(raw: &str) -> Option<&'static str> { @@ -43,36 +59,320 @@ fn normalize(raw: &str) -> Option<&'static str> { .map(|(id, _, _)| *id) } -pub(crate) fn valid(layout: &Layout) -> bool { - layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str())) +/// The panel that holds the document area. It never moves, floats, closes or takes tabs. +pub(crate) const CANVAS: &str = "canvas"; +/// Width of an expanded dock column. +const COLUMN_WIDTH: f32 = 260.0; +/// Width of an iconic column of bare icons, and of one whose groups show their names. +const ICON_WIDTH: f32 = 37.0; +const NAMED_ICON_WIDTH: f32 = 150.0; +/// Height of the group a workspace opens below Properties · Pages · Layers. +const OPEN_PANEL_HEIGHT: f32 = 260.0; + +fn registered(id: &str) -> bool { + id == CANVAS || normalize(id) == Some(id) } -fn legacy(app: &DesignApp) -> Layout { - let panels: Vec = crate::dock::DOCK_TABS.iter().map(|p| p.0.to_string()).collect(); - let active = panels.iter().position(|p| p == &app.ui.dock_tab).unwrap_or(0); - let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() }; - for (id, at) in &app.ui.floating { - if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) { - layout.floating.push(Floating { panels: vec![id.clone()], active: 0, rect: [at[0], at[1], 280.0, 360.0] }); +/// The policy commands and the renderer apply: the canvas is protected. +fn model_permissions(id: &String) -> Permissions { + if id == CANVAS { Permissions::PROTECTED } else { Permissions::default() } +} + +pub(crate) fn valid(layout: &Layout) -> bool { + layout.validate().is_ok() + && layout.panels().iter().all(|id| registered(id)) + && Layout { root: layout.root.clone(), floating: Vec::new() }.contains(&CANVAS.to_string()) +} + +/// Panels already given a place, so each appears once. +#[derive(Default)] +struct Placed(Vec); + +impl Placed { + /// The registered id of `raw` if no other place holds it yet. + fn take(&mut self, raw: &str) -> Option { + let id = normalize(raw)?; + if self.0.iter().any(|p| p == id) { + return None; + } + self.0.push(id.to_string()); + Some(id.to_string()) + } + + /// A group of the unplaced registered panels of `panels`, with the one at `active` in front + /// when it is among them. + fn group(&mut self, panels: &[impl AsRef], active: usize) -> Option> { + let front = panels.get(active).and_then(|p| normalize(p.as_ref())); + let kept: Vec = panels.iter().filter_map(|p| self.take(p.as_ref())).collect(); + let active = front.and_then(|f| kept.iter().position(|p| p == f)).unwrap_or(0); + (!kept.is_empty()).then(|| Group::new(kept, active)) + } +} + +/// The iconic column's groups, top to bottom, in a workspace without its own `icon_groups`: +/// InDesign's panel families, with a separator only between families. +const ICON_GROUPS: &[&[&str]] = &[ + &["stroke", "color", "swatches", "gradient"], + &["effects"], + &["characterStyles", "paragraphStyles"], + &["character", "paragraph", "typography"], + &["glyphs", "story"], + &["table"], + &["textWrap"], + &["align", "pathfinder"], + &["flexLayout", "liquid"], + &["links", "library"], + &["info"], + &["attributes", "preflight"], + &["notes", "trackChanges", "conditions"], + &["hyperlinks", "bookmarks"], + &["buttons", "states"], + &["media", "transitions"], + &["articles", "tags"], + &["book", "dataMerge", "scripts"], +]; + +/// A workspace's iconic column of the panels not placed yet: its named `icon_groups` +/// (Essentials Classic, Typography), or [`ICON_GROUPS`] followed by any other dock panel as a +/// group of its own. +fn icon_column(icon_groups: Option<&[Vec]>, placed: &mut Placed) -> Option> { + let (groups, width): (Vec>, f32) = match icon_groups { + Some(groups) => (groups.iter().filter_map(|g| placed.group(g, 0)).collect(), NAMED_ICON_WIDTH), + None => { + let mut groups: Vec> = ICON_GROUPS.iter().filter_map(|g| placed.group(g, 0)).collect(); + groups.extend( + crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).filter_map(|p| placed.take(p.0)).map(|id| Group::new(vec![id], 0)), + ); + (groups, ICON_WIDTH) + } + }; + (!groups.is_empty()).then_some(Column { groups, width: COLUMN_WIDTH, iconic: true, iconic_width: Some(width) }) +} + +/// The canvas beside a dock of `columns` (from the window edge inwards) at the logical end edge. +fn with_canvas(columns: Vec>, floating: Vec>) -> Layout { + let width: f32 = columns.iter().map(|c| if c.iconic { c.iconic_width.unwrap_or(ICON_WIDTH) } else { c.width }).sum(); + Layout { + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::FixedSecond(width), + first: Box::new(Node::Tabs { panels: vec![CANVAS.to_string()], active: 0 }), + second: Box::new(Node::Dock { side: Side::End, columns }), + }), + floating, + } +} + +/// The layout of a workspace that has not been customized, from its panel settings: the +/// canvas beside a dock whose outer column is the Properties · Pages · Layers group (with an +/// the panel the workspace opens stacked below it) and whose inner column is iconic. Essentials Classic +/// and Typography (`icon_groups`) have the iconic column only. +pub(crate) fn default_layout(ui: &crate::UiState) -> Layout { + let mut placed = Placed::default(); + let mut floating = Vec::new(); + for (id, at) in &ui.floating { + if at.iter().all(|v| v.is_finite()) + && let Some(id) = placed.take(id) + { + // The layout is rebuilt every frame, so each window keeps an id of its panel's (a + // native window lives as long as its id). + let window = default_window_id(&id); + floating.push(Floating::single(vec![id], 0, [at[0], at[1], 280.0, 360.0]).with_id(window)); } } - if let Some(id) = &app.ui.open_panel - && normalize(id).is_some() - && !layout.contains(id) - { - // Preserve the visible flyout when the first unrelated docking gesture succeeds. - if let Some(Node::Tabs { panels, active }) = &mut layout.root { - panels.push(id.clone()); - *active = panels.len().saturating_sub(1); + let mut columns = Vec::new(); + if ui.icon_groups.is_none() { + let panels: Vec = crate::dock::DOCK_TABS.iter().filter_map(|p| placed.take(p.0)).collect(); + let active = panels.iter().position(|p| p == &ui.dock_tab).unwrap_or(0); + let mut groups = Vec::new(); + if !panels.is_empty() { + groups.push(Group::new(panels, active)); + } + // The panel a workspace opens (Printing and Proofing: Swatches) goes below at a fixed + // height, so Properties keeps most of the column. + if let Some(id) = ui.open_panel.as_deref().and_then(|id| placed.take(id)) { + groups.push(Group { height: Some(OPEN_PANEL_HEIGHT), ..Group::new(vec![id], 0) }); + } + if !groups.is_empty() { + columns.push(Column { groups, width: COLUMN_WIDTH, iconic: !ui.dock_expanded, iconic_width: Some(ICON_WIDTH) }); } } - layout + columns.extend(icon_column(ui.icon_groups.as_deref(), &mut placed)); + with_canvas(columns, floating) +} + +/// The window id of a default layout's floating `panel`: the same every time, with the top bit +/// set, where craft-ui's fresh ids never reach. +fn default_window_id(panel: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + ("designcraft-default-float", panel).hash(&mut h); + h.finish() | 1 << 63 +} + +/// Is `layout` from before the canvas joined the panel layout? Those layouts held only the +/// right dock's panels and floating windows; every later one holds the canvas. +pub(crate) fn dock_only(layout: &Layout) -> bool { + !layout.panels().iter().any(|p| p.as_str() == CANVAS) +} + +/// A [`dock_only`] layout in the current shape: its tab groups, accordions and splits become +/// the dock's expanded columns (collapsed to icons when the dock was), the panels it does not +/// hold form the iconic column, and its floating windows stay. Panels closed with a remembered +/// place (`hidden`) stay closed. None when the layout is invalid. +pub(crate) fn from_dock_only<'a>( + old: &Layout, + hidden: impl IntoIterator, + dock_expanded: bool, + icon_groups: Option<&[Vec]>, +) -> Option> { + old.validate().ok()?; + let mut placed = Placed::default(); + // Reading order (start to end); a dock lists its columns from the window edge inwards. + let mut reading = old.root.as_ref().map(|root| dock_only_columns(root, 0, &mut placed)).unwrap_or_default(); + reading.reverse(); + let mut columns: Vec> = + reading.into_iter().map(|groups| Column { groups, width: COLUMN_WIDTH, iconic: !dock_expanded, iconic_width: Some(ICON_WIDTH) }).collect(); + let mut floating = Vec::new(); + for window in &old.floating { + if let Some(root) = unplaced_tree(&window.root, 0, &mut placed) { + floating.push(Floating { minimized: window.minimized, ..Floating::new(root, window.rect) }); + } + } + for panel in hidden { + let _ = placed.take(panel); + } + columns.extend(icon_column(icon_groups, &mut placed)); + let layout = with_canvas(columns, floating); + valid(&layout).then_some(layout) +} + +/// A floating window's tree with only the panels not placed yet; a split that loses one side +/// becomes its other side. None when no panel is left. +fn unplaced_tree(node: &Node, depth: usize, placed: &mut Placed) -> Option> { + if depth > craft_ui::docking::MAX_DEPTH { + return None; + } + match node { + Node::Tabs { panels, active } => placed.group(panels, *active).map(|g| Node::Tabs { panels: g.panels, active: g.active }), + Node::Stack { entries } => { + let entries: Vec<_> = + entries.iter().filter_map(|e| placed.take(&e.panel).map(|panel| craft_ui::docking::StackEntry { panel, ..e.clone() })).collect(); + (!entries.is_empty()).then_some(Node::Stack { entries }) + } + Node::Split { axis, size, first, second } => { + let first = unplaced_tree(first, depth.saturating_add(1), placed); + let second = unplaced_tree(second, depth.saturating_add(1), placed); + match (first, second) { + (Some(first), Some(second)) => Some(Node::Split { axis: *axis, size: *size, first: Box::new(first), second: Box::new(second) }), + (one, other) => one.or(other), + } + } + _ => None, + } +} + +/// The groups of a floating window, first to last: its tab groups and accordions. +pub(crate) fn window_groups(window: &Floating) -> Vec> { + let mut out = Vec::new(); + let mut visit = vec![(&window.root, 0usize)]; + while let Some((node, depth)) = visit.pop() { + if depth > craft_ui::docking::MAX_DEPTH || out.len() > craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push((second, depth.saturating_add(1))); + visit.push((first, depth.saturating_add(1))); + } + Node::Tabs { panels, active } => out.push(Group::new(panels.clone(), *active)), + Node::Stack { entries } => out.push(Group::new(entries.iter().map(|e| e.panel.clone()).collect(), 0)), + _ => {} + } + } + out +} + +/// The groups of a dock-only layout's node, column by column in reading order. A vertical split +/// stacks its parts in one column; a horizontal split puts them side by side. +fn dock_only_columns(node: &Node, depth: usize, placed: &mut Placed) -> Vec>> { + if depth > craft_ui::docking::MAX_DEPTH { + return Vec::new(); + } + let column = |groups: Vec>| if groups.is_empty() { Vec::new() } else { vec![groups] }; + match node { + Node::Tabs { panels, active } => column(placed.group(panels, *active).into_iter().collect()), + Node::Stack { entries } => column( + entries + .iter() + .filter_map(|e| placed.take(&e.panel).map(|id| Group { minimized: !e.open, height: e.height, ..Group::new(vec![id], 0) })) + .collect(), + ), + Node::Split { axis, first, second, .. } => { + let mut parts = dock_only_columns(first, depth.saturating_add(1), placed); + parts.extend(dock_only_columns(second, depth.saturating_add(1), placed)); + match axis { + craft_ui::layout::SplitAxis::Horizontal => parts, + _ => column(parts.into_iter().flatten().collect()), + } + } + Node::Dock { columns, .. } => columns + .iter() + .rev() + .filter_map(|c| { + let groups: Vec> = c.groups.iter().filter_map(|g| placed.group(&g.panels, g.active)).collect(); + (!groups.is_empty()).then_some(groups) + }) + .collect(), + _ => Vec::new(), + } +} + +/// The layout in use: the customized one, or the workspace's default. +fn layout_of(app: &DesignApp) -> Layout { + app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)) +} + +/// Is `panel` showing: in front of its group and not in a column collapsed to icons, or in +/// the open flyout of an iconic column? +pub(crate) fn showing(app: &DesignApp, panel: &str) -> bool { + let layout = layout_of(app); + layout.panel_state(&panel.to_string()) == craft_ui::docking::PanelState::Front + || (app.ui.docking_flyout.as_deref() == Some(panel) && in_iconic_column(&layout, panel)) +} + +/// Is `panel` in a dock column collapsed to icons? +fn in_iconic_column(layout: &Layout, panel: &str) -> bool { + dock_columns(layout).iter().any(|c| c.iconic && c.groups.iter().any(|g| g.panels.iter().any(|p| p == panel))) +} + +/// Every dock column, from each dock's window edge inwards. +fn dock_columns(layout: &Layout) -> Vec<&Column> { + let mut out = Vec::new(); + let mut visit: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => { + visit.push(second); + visit.push(first); + } + Node::Dock { columns, .. } => out.extend(columns), + _ => {} + } + } + out } /// The panels tabbed with `panel`: its floating group, or its tab group in the dock. A panel in /// an accordion, or not in the layout, is a group of its own. -fn tab_group(layout: &Layout, panel: &str) -> Vec { - if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == panel)) { +#[cfg(test)] +pub(crate) fn tab_group(layout: &Layout, panel: &str) -> Vec { + if let Some(group) = layout.floating.iter().flat_map(window_groups).find(|g| g.panels.iter().any(|p| p == panel)) { return group.panels.clone(); } let mut visit: Vec<&Node> = layout.root.iter().collect(); @@ -88,12 +388,63 @@ fn tab_group(layout: &Layout, panel: &str) -> Vec { visit.push(second); } Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return panels.clone(), + Node::Dock { columns, .. } => { + if let Some(group) = columns.iter().flat_map(|c| &c.groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return group.panels.clone(); + } + } _ => {} } } vec![panel.to_string()] } +/// Where a panel is: its floating window and group, or the path to its tab group, accordion or +/// dock in the tree with the dock column and group. A different place means the panel moved. +#[derive(Debug, PartialEq)] +enum Place { + Floating { window: usize, group: usize }, + Tree { path: Vec, column: usize, group: usize }, +} + +fn place(layout: &Layout, panel: &str) -> Option { + let has = |group: &Group| group.panels.iter().any(|p| p == panel); + for (window, floating) in layout.floating.iter().enumerate() { + if let Some(group) = window_groups(floating).iter().position(has) { + return Some(Place::Floating { window, group }); + } + } + let mut visit: Vec<(&Node, Vec)> = layout.root.iter().map(|node| (node, Vec::new())).collect(); + let mut seen = 0usize; + while let Some((node, path)) = visit.pop() { + seen = seen.saturating_add(1); + if seen > 2 * craft_ui::docking::MAX_PANELS || path.len() > craft_ui::docking::MAX_DEPTH { + return None; + } + match node { + Node::Split { first, second, .. } => { + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + visit.push((second, second_path)); + visit.push((first, first_path)); + } + Node::Tabs { panels, .. } if panels.iter().any(|p| p == panel) => return Some(Place::Tree { path, column: 0, group: 0 }), + Node::Stack { entries } if entries.iter().any(|e| e.panel == panel) => return Some(Place::Tree { path, column: 0, group: 0 }), + Node::Dock { columns, .. } => { + for (column, c) in columns.iter().enumerate() { + if let Some(group) = c.groups.iter().position(has) { + return Some(Place::Tree { path, column, group }); + } + } + } + _ => {} + } + } + None +} + /// Is `panel` an open entry of an accordion (which Minimize closes)? fn open_in_stack(layout: &Layout, panel: &str) -> bool { let mut visit: Vec<&Node> = layout.root.iter().collect(); @@ -113,20 +464,27 @@ fn open_in_stack(layout: &Layout, panel: &str) -> bool { return e.open; } } - Node::Tabs { .. } => {} + _ => {} } } false } -fn first_root(layout: &Layout, except: &str) -> Option { - let root = Layout { root: layout.root.clone(), floating: Vec::new() }; - root.panels().into_iter().find(|p| p.as_str() != except).cloned() +/// Where a panel with no other destination goes: a panel of the first expanded dock column, +/// else of any column, else of the root. +fn first_dock_panel(layout: &Layout, except: &str) -> Option { + let usable = |p: &&String| p.as_str() != except && p.as_str() != CANVAS; + let columns = dock_columns(layout); + let in_columns = + |iconic: bool| columns.iter().filter(|c| c.iconic == iconic).flat_map(|c| &c.groups).flat_map(|g| &g.panels).find(usable).cloned(); + in_columns(false).or_else(|| in_columns(true)).or_else(|| { + let root = Layout { root: layout.root.clone(), floating: Vec::new() }; + root.panels().into_iter().find(usable).cloned() + }) } -fn ensure(layout: &mut Layout, 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 apply(layout: &mut Layout, action: Action) -> Result, String> { + layout.apply_with_permissions(action, model_permissions).map_err(|e| e.to_string()) } fn number(params: &Value, key: &str, default: f32) -> Result { @@ -140,73 +498,370 @@ fn number(params: &Value, key: &str, default: f32) -> Result { } } -fn placement(params: &Value) -> Result, 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()) }); +/// The shared docking command a DesignCraft command id names. `window.floatPanel` and +/// `window.dockPanel` are DesignCraft's names for Float Panel and Dock Panel. `window.workspace` +/// stays DesignCraft's own command (same id and params), with the other workspace commands. +fn shared_id(id: &str) -> Option<&'static str> { + match id { + "window.floatPanel" => Some("window.panel.float"), + "window.dockPanel" => Some("window.panel.dock"), + "window.workspace" => None, + _ => DockCommand::::IDS.iter().copied().find(|known| *known == id), } - 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 DesignApp, id: &str, params: &Value) -> Option> { - if id == "app.tablePanel" && app.ui.docking.is_some() { - let panel = "table".to_string(); - let visible = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel)); - return Some(change(app, if visible { "close" } else { "activate" }, &json!({"panel": panel}))); + if id == "app.tablePanel" { + // A Table flyout closes; the panel stays in its icon group. + if flyout_open(app, "table") { + return Some(Ok(close_flyout(app))); + } + let id = if showing(app, "table") { "window.panel.close" } else { "window.panel.activate" }; + return Some(shared_command(app, id, &json!({"panel": "table"}))); } - if id == "window.panel.closeGroup" { - return Some(close_group(app, params)); + if id == "window.collapseToIcons" { + return Some(set_iconic(app, true)); } - if id == "window.panel" - && let Some(result) = open_floating_group(app, params) + if matches!(id, "window.floatPanel" | "window.dockPanel" | "window.panel.float" | "window.panel.dock") + && let Some(panel) = params.get("panel").and_then(Value::as_str).filter(|p| crate::dock::float_only(p)) { - return Some(result); + return Some(float_only_panel(app, id, panel, 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::>(value.clone()).map_err(|error| error.to_string())) - .and_then(|action| apply_action(app, action)), - ); + if id == "window.panel" { + if let Some(result) = open_floating_group(app, params) { + return Some(result); + } + // Float-only panels (Type › Tabs) are not in the layout. + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize)?; + // Choosing a panel whose flyout is open closes the flyout, as the panel's icon does. + if flyout_open(app, panel) { + return Some(Ok(close_flyout(app))); + } + return Some(shared_command(app, "window.panel.activate", params)); } - let operation = match id { - "window.panel.move" => "move", - "window.panel.float" | "window.floatPanel" => "float", - "window.panel.dock" | "window.dockPanel" => "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)) + let id = shared_id(id)?; + Some(shared_command(app, id, params)) } -// 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 DesignApp, action: Action) -> Result { - let registered = |id: &String| normalize(id) == Some(id.as_str()); - let known = match &action { +/// Is `panel` showing in the open flyout of an iconic column? +fn flyout_open(app: &DesignApp, panel: &str) -> bool { + app.ui.docking_flyout.as_deref() == Some(panel) && in_iconic_column(&layout_of(app), panel) +} + +/// Close the open flyout; the layout is unchanged. +fn close_flyout(app: &mut DesignApp) -> Value { + app.ui.docking_flyout = None; + if let Some(ctx) = app.egui_ctx.as_ref() { + craft_ui::docking::close_flyout::(ctx, area_id()); + } + json!({"layout": layout_of(app), "closed": [], "floating": floating_panels(app)}) +} + +/// Command params with DesignCraft's panel names resolved (a panel id or English name, in any +/// case) and `onto` read as `anchor`. +fn resolve_panels(params: &Value) -> Result { + let mut params = if params.is_null() { json!({}) } else { params.clone() }; + let fields = params.as_object_mut().ok_or("params must be an object")?; + if let Some(onto) = fields.remove("onto") { + fields.entry("anchor").or_insert(onto); + } + for key in ["panel", "anchor", "target", "before"] { + let Some(value) = fields.get_mut(key) else { continue }; + if key == "before" && value.is_null() { + continue; + } + let id = value.as_str().and_then(normalize).ok_or_else(|| format!("{key} must name a registered panel"))?; + *value = Value::String(id.to_owned()); + } + Ok(params) +} + +/// A shared docking command (`window.panel.*`, `window.workspace.*`, `ui.dock`) on the layout in +/// use, all or nothing. craft-ui's `DockCommand` parses it and makes the layout actions; +/// DesignCraft resolves panel names, shows a hidden panel before moving, floating or docking +/// it, and returns a floated panel to its dock place on Dock Panel. +fn shared_command(app: &mut DesignApp, id: &str, params: &Value) -> Result { + let mut params = resolve_panels(params)?; + if let Some(fields) = params.as_object_mut() { + fields.insert("command".into(), Value::String(id.to_owned())); + } + let command: DockCommand = serde_json::from_value(params).map_err(|e| e.to_string())?; + match &command { + DockCommand::Workspace { name } => return app.run("window.workspace", json!({"name": name})), + DockCommand::NewWorkspace { name } => return app.run("window.newWorkspace", json!({"name": name})), + DockCommand::DeleteWorkspace { name } => return app.run("window.deleteWorkspace", json!({"name": name})), + DockCommand::ResetWorkspace {} => return app.run("window.resetWorkspace", json!({})), + DockCommand::Lock { .. } => return Err("Lock Workspace is not available in DesignCraft".into()), + DockCommand::Window { .. } => { + return Err( + "Move Panel to New Window is not available: a floating panel has its own window on the desktop and stays in the page on the web" + .into(), + ); + } + _ => {} + } + // A zone is a side of `anchor`. DesignCraft's docks are columns beside the canvas, so a dock at + // a window edge is a `side`, not a frame on the outer edge (craft-ui's `Placement::Edge`). + if let DockCommand::Dock { anchor: None, side: None, zone: Some(_), .. } = &command { + return Err("zone needs an anchor: dock beside a panel with anchor, or in a new dock column with side (Start or End)".into()); + } + let rtl = crate::i18n::is_rtl(&app.ui.language); + let space = float_space(app); + let mut txn = Transaction::begin(app)?; + let shows = match &command { + DockCommand::Activate { panel } + | DockCommand::Move { panel, .. } + | DockCommand::Float { panel, .. } + | DockCommand::Dock { panel, .. } + | DockCommand::UiDock(UiDock::Operation(DockOperation::Open { panel } | DockOperation::Activate { panel })) => Some(panel.clone()), + _ => None, + }; + let mut shown = false; + if let Some(panel) = shows.as_ref().filter(|p| !txn.workspace.layout.contains(p)) { + let fallback = first_dock_panel(&txn.workspace.layout, panel); + txn.workspace.set_visible(panel.clone(), true, fallback, model_permissions).map_err(|e| e.to_string())?; + shown = true; + } + let returned = match &command { + DockCommand::Dock { panel, anchor: None, side: None, group, column: false, .. } if !shown => { + txn.dock_group_back(panel)? || (!group && txn.dock_back(panel)?) + } + _ => false, + }; + let actions = match &command { + // Showing it was the whole command (craft-ui's `DockCommand::apply` does the same). + DockCommand::Dock { anchor: None, side: None, .. } if shown || returned => Vec::new(), + _ => command.to_actions(&txn.workspace, rtl, &space).map_err(|e| e.to_string())?, + }; + let reveal = match &command { + DockCommand::Layout { action } | DockCommand::UiDock(UiDock::Action { action }) => match action { + Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), + _ => None, + }, + DockCommand::UiDock(UiDock::Operation( + DockOperation::Float { panel, .. } | DockOperation::MoveFloating { panel, .. } | DockOperation::Move { panel, .. }, + )) => Some(panel.clone()), + _ => shows, + }; + let mut closed = Vec::new(); + for action in actions { + closed.extend(txn.apply(action)?); + } + Ok(txn.commit(app, closed, reveal)) +} + +/// Where floating windows may go: the window, below the title-bar strip when DesignCraft draws +/// its own title bar under macOS's transparent one. A floating window in that strip would take +/// presses that move the whole app window. +fn float_bounds(app: &DesignApp, ctx: &egui::Context) -> egui::Rect { + let mut r = ctx.content_rect(); + if app.integrated_titlebar { + r.min.y = (r.min.y + crate::chrome::MAC_APP_BAR).min(r.max.y); + } + r +} + +/// Where a command floats a panel, group or column it gives no rect: inside the dock area as +/// last drawn, else inside the window, else (headless, before any frame) inside +/// [`HEADLESS_AREA`]. +/// Seeded with every panel's own size, so a panel the area never drew (shown from hidden by the +/// same command, or headless) floats at that size too. +fn float_space(app: &DesignApp) -> FloatSpace { + let space = match app.egui_ctx.as_ref() { + Some(ctx) => craft_ui::docking::float_space::(ctx, area_id()).unwrap_or_else(|| FloatSpace::new(float_bounds(app, ctx))), + None => FloatSpace::new(HEADLESS_AREA), + }; + crate::dock::DOCK_TABS + .iter() + .chain(crate::dock::ICON_PANELS) + .fold(space, |space, (id, _, _)| space.with_panel_size((*id).to_owned(), floating_size(id))) +} + +/// A change to the layout in use, committed only when every step succeeded. +struct Transaction { + /// The layout, and the closed panels with where each one goes back. + workspace: Workspace, + /// Where each floating panel was docked before it floated, for Dock Panel. + returns: crate::HiddenPanels, +} + +impl Transaction { + fn begin(app: &DesignApp) -> Result { + 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 layout = layout_of(app); + let mut workspace = Workspace::new(layout); + let mut returns = crate::HiddenPanels::default(); + for (panel, location) in + app.ui.docking_hidden.0.iter().filter(|(id, _)| normalize(id) == Some(id.as_str())).take(craft_ui::docking::MAX_PANELS) + { + if !workspace.layout.contains(panel) { + workspace.hidden.push((panel.clone(), location.clone())); + } else if workspace.layout.floating.iter().any(|w| w.contains(panel)) { + returns.insert(panel.clone(), location.clone()); + } + } + Ok(Self { workspace, returns }) + } + + /// Dock Panel for a floating panel that floated from the dock: back to that place, while + /// the panel it was beside is still docked. True when it went back. + fn dock_back(&mut self, panel: &String) -> Result { + let layout = &self.workspace.layout; + if !layout.floating.iter().any(|w| w.contains(panel)) { + return Ok(false); + } + let docked = Layout { root: layout.root.clone(), floating: Vec::new() }; + let Some(location) = self + .returns + .get(panel) + .filter(|location| location.floating.is_none() && location.anchor.as_ref().is_none_or(|anchor| docked.contains(anchor))) + else { + return Ok(false); + }; + let mut candidate = layout.clone(); + apply(&mut candidate, Action::Close { panel: panel.clone() })?; + if candidate.restore(panel.clone(), location).is_err() { + return Ok(false); + } + self.workspace.layout = candidate; + self.returns.remove(panel); + Ok(true) + } + + /// Dock Panel for a panel of a floating group that floated from the dock as one: the whole + /// group back to that place, in the same column beside the same neighbour, while that + /// neighbour is still docked. True when it went back. + fn dock_group_back(&mut self, panel: &String) -> Result { + let layout = &self.workspace.layout; + let Some(group) = layout.floating.iter().flat_map(window_groups).find(|g| g.panels.contains(panel)) else { + return Ok(false); + }; + let Some(location) = self.returns.get(panel).cloned() else { + return Ok(false); + }; + // A group of one docks back as a panel. Every member keeps the same group place only + // while the group is the one that floated. + if group.panels.len() < 2 || !group.panels.iter().all(|p| self.returns.get(p) == Some(&location)) { + return Ok(false); + } + let docked = Layout { root: layout.root.clone(), floating: Vec::new() }; + let Some(anchor) = location.anchor.clone().filter(|anchor| docked.contains(anchor)) else { + return Ok(false); + }; + if !matches!(location.placement, Placement::Group { .. } | Placement::Column { .. }) { + return Ok(false); + } + let mut candidate = layout.clone(); + let to = Target::At { anchor, placement: location.placement.clone() }; + if apply(&mut candidate, Action::MoveGroup { panel: panel.clone(), to }).is_err() { + return Ok(false); + } + let mut state = Vec::new(); + if let Some(saved) = &location.group { + state.push(Action::ResizeGroup { panel: panel.clone(), height: saved.height }); + state.push(Action::SetMinimized { panel: panel.clone(), minimized: saved.minimized }); + // A group that had a column of its own gets that column back as it was. + if let (Placement::Column { .. }, Some(column)) = (&location.placement, saved.column) { + state.push(Action::ResizeColumn { panel: panel.clone(), width: column.width }); + state.push(Action::SetIconic { panel: panel.clone(), iconic: column.iconic }); + } + } + for action in state { + apply(&mut candidate, action)?; + } + self.workspace.layout = candidate; + for member in &group.panels { + self.returns.remove(member); + } + Ok(true) + } + + /// One action from a command or the renderer, for registered panels only. Floating a + /// docked panel, or its whole group, remembers its place for Dock Panel. + fn apply(&mut self, action: Action) -> Result, String> { + if !known(&action) { + return Err("docking action contains an unknown panel or an excessive split path".into()); + } + if let Action::Float { panel, .. } = &action + && let Ok(location) = self.workspace.layout.location(panel) + && location.floating.is_none() + { + self.returns.insert(panel.clone(), location); + } + if let Action::MoveGroup { panel, to: Target::Float { .. } } = &action + && let Some((members, location)) = docked_group_place(&self.workspace.layout, panel) + { + for member in members { + self.returns.insert(member, location.clone()); + } + } + self.workspace.apply(action, model_permissions).map_err(|e| e.to_string()) + } + + /// Store the result. `reveal` is the panel the command showed, raised (or its flyout opened) + /// on the next frame. + fn commit(self, app: &mut DesignApp, closed: Vec, reveal: Option) -> Value { + let Self { workspace, returns } = self; + let layout = workspace.layout; + let floating = |p: &String| layout.floating.iter().any(|w| w.contains(p)); + let mut hidden = crate::HiddenPanels(workspace.hidden); + for (panel, location) in returns.0 { + if floating(&panel) && !hidden.contains_key(&panel) { + hidden.0.push((panel, location)); + } + } + let reveal = reveal.filter(|panel| layout.contains(panel)); + let flyout = reveal.as_ref().filter(|panel| in_iconic_column(&layout, panel)).cloned(); + // Focus stays on a panel only while it stays where the user left it, as craft-ui's tab + // focus does: a command that moves it gives the focus up. + let before = layout_of(app); + if app.ui.docking_focused.as_deref().is_some_and(|panel| panel != CANVAS && place(&before, panel) != place(&layout, panel)) { + app.ui.docking_focused = None; + } + app.ui.docking = Some(layout); + app.ui.docking_hidden = hidden; + release_closed(app, &closed); + keep_float_only(app); + if let Some(panel) = reveal { + app.ui.docking_opened = Some(panel.clone()); + app.ui.docking_raise = Some(panel); + app.ui.docking_flyout = flyout; + } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { + app.ui.docking_opened = None; + } + release_flyout(app); + app.ui.open_panel = None; + json!({"layout": app.ui.docking, "closed": closed, "floating": floating_panels(app)}) + } +} + +/// The panels of the docked group holding `panel`, and that group's place as a whole: beside +/// the group or column next to it, with its group and column state. None when `panel` is not +/// in a dock. +fn docked_group_place(layout: &Layout, panel: &String) -> Option<(Vec, craft_ui::docking::Location)> { + let members = dock_columns(layout).into_iter().flat_map(|c| &c.groups).find(|g| g.panels.contains(panel))?.panels.clone(); + // The place of the group's last panel left in it is the group's own place. + let mut alone = layout.clone(); + for other in members.iter().filter(|p| *p != panel) { + apply(&mut alone, Action::Close { panel: other.clone() }).ok()?; + } + let location = alone.location(panel).ok().filter(|l| l.floating.is_none())?; + Some((members, location)) +} + +/// Whether every panel an action names is registered (and a split path is not excessive). +/// The application owns the registry; craft-ui owns tree validation and structural changes. +fn known(action: &Action) -> bool { + let registered = |id: &String| self::registered(id); + let tab = |placement: &Placement| match placement { + Placement::Tab { before: Some(id) } => registered(id), + _ => true, + }; + 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, - } + registered(panel) && registered(anchor) && tab(placement) } Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered), Action::Float { panel, .. } @@ -216,93 +871,67 @@ fn apply_action(app: &mut DesignApp, action: Action) -> Result 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 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 reveal = match &action { - Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), - _ => None, - }; - layout.apply(action).map_err(|error| error.to_string())?; - if let Some((panel, location)) = returning { - app.ui.docking_hidden.insert(panel, location); - } - app.ui.docking = Some(layout); - sync_legacy(app); - if let Some(panel) = reveal { - app.ui.docking_opened = Some(panel.clone()); - app.ui.docking_raise = Some(panel); - app.ui.dock_expanded = true; - } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { - app.ui.docking_opened = None; - } - app.ui.open_panel = None; - Ok(json!({"layout": app.ui.docking})) -} - -/// The panel in front of each tab group in the dock (not the floating groups). -fn front_tabs(layout: &Layout) -> Vec { - let mut out = Vec::new(); - let mut visit: Vec<&Node> = layout.root.iter().collect(); - while let Some(node) = visit.pop() { - if out.len() > craft_ui::docking::MAX_PANELS { - break; - } - match node { - Node::Split { first, second, .. } => { - visit.push(first); - visit.push(second); - } - Node::Tabs { panels, active } => out.extend(panels.get(*active).cloned()), - Node::Stack { .. } => {} - } - } - out -} - -/// Bring each of `front` back in front of the dock tab group that holds it. -fn restore_front_tabs(layout: &mut Layout, front: &[String]) { - let mut visit: Vec<&mut Node> = layout.root.iter_mut().collect(); - let mut seen = 0usize; - while let Some(node) = visit.pop() { - seen = seen.saturating_add(1); - if seen > 2 * craft_ui::docking::MAX_PANELS { - break; - } - match node { - Node::Split { first, second, .. } => { - visit.push(first); - visit.push(second); - } - Node::Tabs { panels, active } => { - if let Some(i) = panels.iter().position(|p| front.contains(p)) { - *active = i; + Action::ResizeWindowSplit { panel, path, .. } => registered(panel) && path.len() <= craft_ui::docking::MAX_DEPTH, + Action::MovePanel { panel, to } | Action::MoveGroup { panel, to } | Action::MoveColumn { panel, to } | Action::MoveWindow { panel, to } => { + registered(panel) + && match to { + Target::At { anchor, placement } => registered(anchor) && tab(placement), + _ => true, } - } - Node::Stack { .. } => {} } + Action::CloseGroup { panel } + | Action::CloseWindow { panel } + | Action::CloseOthers { panel } + | Action::SetMinimized { panel, .. } + | Action::SetIconic { panel, .. } + | Action::ResizeColumn { panel, .. } + | Action::ResizeGroup { panel, .. } + | Action::ResizeIconic { panel, .. } + | Action::SetNative { panel, .. } + | Action::SetMonitor { panel, .. } => registered(panel), + // Actions newer than this list are rejected until DesignCraft checks them. + _ => false, } } +/// Type › Tabs and the other float-only panels: their own windows outside the docking layout, +/// listed in `UiState::floating` with their top-left positions. +fn float_only_panel(app: &mut DesignApp, id: &str, panel: &str, params: &Value) -> Result { + let at = if matches!(id, "window.floatPanel" | "window.panel.float") { + Some([number(params, "x", 400.0)?, number(params, "y", 160.0)?]) + } else { + None + }; + app.ui.floating.retain(|(p, _)| p != panel); + if let Some(at) = at { + app.ui.floating.push((panel.to_string(), at)); + } + Ok(json!({"panel": panel, "floating": floating_panels(app)})) +} + +/// Once the layout is customized it holds every floating dock panel, so `UiState::floating` +/// keeps only the float-only panels. +fn keep_float_only(app: &mut DesignApp) { + if app.ui.docking.is_some() { + app.ui.floating.retain(|(id, _)| crate::dock::float_only(id)); + } +} + +/// Every floating panel: the layout's floating windows, then the float-only panels. +pub(crate) fn floating_panels(app: &DesignApp) -> Vec { + let layout = layout_of(app); + let docked = layout.floating.iter().flat_map(|w| w.panels().cloned()); + docked.chain(app.ui.floating.iter().filter(|(id, _)| crate::dock::float_only(id)).map(|(id, _)| id.clone())).collect() +} + /// Panels InDesign opens together as one floating group from the Window menu, in tab order: /// Window › Styles › Paragraph Styles (or Character Styles) brings both. const FLOATING_GROUPS: &[&[&str]] = &[&["characterStyles", "paragraphStyles"]]; /// Window › a panel of a [`FLOATING_GROUPS`] group none of whose panels is showing: the group -/// opens floating over the right of the canvas, with that panel in front. None otherwise (the -/// panel opens as before). +/// opens as one floating window over the right of the canvas, with that panel in front. A group +/// that is an icon group of the default workspaces floats out of the iconic column. None +/// otherwise (the panel opens as before). fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option> { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize)?; let group = FLOATING_GROUPS.iter().find(|g| g.contains(&panel))?; @@ -311,127 +940,92 @@ fn open_floating_group(app: &mut DesignApp, params: &Value) -> Option Result { - let first = group.first().ok_or("empty panel group")?; - change(app, "float", &json!({"panel": first, "x": x, "y": y, "width": w, "height": h}))?; - for p in group.get(1..).unwrap_or_default() { - change(app, "move", &json!({"panel": p, "anchor": first}))?; - } - change(app, "activate", &json!({"panel": panel})) + let mut txn = Transaction::begin(app)?; + let panels: Vec = group.iter().map(|p| (*p).to_string()).collect(); + let active = group.iter().position(|p| *p == panel).unwrap_or(0); + let mut layout = txn.workspace.layout.clone(); + layout.floating.push(Floating::single(panels, active, [x, y, w, h])); + layout.validate().map_err(|e| e.to_string())?; + txn.workspace.layout = layout; + txn.workspace.hidden.retain(|(p, _)| !group.contains(&p.as_str())); + Ok(txn.commit(app, Vec::new(), Some(panel.to_string()))) }; - let result = open(app); - if result.is_ok() - && let Some(layout) = app.ui.docking.as_mut() - { - restore_front_tabs(layout, &front); - } - if result.is_err() { - // All or nothing. - (app.ui.docking, app.ui.docking_hidden, app.ui.floating, app.ui.open_panel) = before; - } - Some(result) + Some(open(app)) } -fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result { - let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; +/// Forget what DesignCraft keeps about panels that just closed: the focus, the panel last +/// opened (for `ui.set closePanel`) and a pending raise. +fn release_closed(app: &mut DesignApp, closed: &[String]) { + for slot in [&mut app.ui.docking_focused, &mut app.ui.docking_opened, &mut app.ui.docking_raise, &mut app.ui.docking_flyout] { + if slot.as_ref().is_some_and(|panel| closed.contains(panel)) { + *slot = None; + } + } +} + +/// Forget the panel focused, opened, raised or showing as a flyout: the workspace was replaced. +pub(crate) fn forget_panels(app: &mut DesignApp) { + app.ui.docking_focused = None; + app.ui.docking_opened = None; + app.ui.docking_raise = None; + app.ui.docking_flyout = None; +} + +/// A flyout opens only from an iconic column; forget it once its panel is no longer in one. +fn release_flyout(app: &mut DesignApp) { + let layout = layout_of(app); + if app.ui.docking_flyout.as_deref().is_some_and(|panel| !in_iconic_column(&layout, panel)) { + app.ui.docking_flyout = None; + } +} + +/// Window › Collapse to Icons collapses every dock column; `iconic: false` (the control +/// channel's `dockExpanded`) expands the outer column. +pub(crate) fn set_iconic(app: &mut DesignApp, iconic: bool) -> Result { 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 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 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 rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?]; - ensure(&mut layout, panel)?; - if let Ok(location) = layout.location(&panel.to_string()) - && location.floating.is_none() - { - hidden.insert(panel.into(), location); - } - layout.apply(Action::Float { panel: panel.into(), rect }).map_err(|e| e.to_string())?; - } - "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); - } + let mut layout = layout_of(app); + let columns = dock_columns(&layout); + let firsts = |c: &&Column| c.groups.first().and_then(|g| g.panels.first()).cloned(); + let targets: Vec = if iconic { + columns.iter().filter(|c| !c.iconic).filter_map(firsts).collect() + } else { + columns.first().filter(|c| c.iconic).and_then(firsts).into_iter().collect() + }; + for panel in targets { + apply(&mut layout, Action::SetIconic { panel, iconic })?; } app.ui.docking = Some(layout); - app.ui.docking_hidden = hidden; - sync_legacy(app); + app.ui.dock_expanded = !iconic; app.ui.open_panel = None; - app.ui.docking_opened = if operation == "close" { None } else { Some(panel.to_owned()) }; - app.ui.docking_raise = if operation == "close" { None } else { Some(panel.to_owned()) }; - if operation != "close" { - app.ui.dock_expanded = true; - } - Ok(json!({"panel": panel, "floating": app.ui.floating.iter().map(|(id, _)| id).collect::>(), "layout": app.ui.docking})) + release_flyout(app); + Ok(Value::Null) } -pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { - let area = egui::Id::unique("designcraft-panel-docking"); +pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + let area = area_id(); let changed_workspace = ui.ctx().data_mut(|data| { let previous = data.get_temp::(area.with("generation")); data.insert_temp(area.with("generation"), app.ui.docking_generation); @@ -439,169 +1033,159 @@ pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { }); if changed_workspace { craft_ui::docking::cancel_drag::(ui.ctx(), area); - ui.ctx().data_mut(|data| data.remove::(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) { + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { app.ui.docking = None; + forget_panels(app); app.status(crate::i18n::tr(&app.ui.language, "Invalid panel layout; restored the default dock")); - return false; - } - let mut layout = layout.clone(); - if !app.ui.dock_expanded { - layout.root = None; } + let layout = layout_of(app); let raise = app.ui.docking_raise.take(); let mut blocked = Vec::new(); - if app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() || egui::Popup::is_any_open(ui.ctx()) { + let modal = modal_open(app); + if modal || egui::Popup::is_any_open(ui.ctx()) { blocked.push(ui.ctx().content_rect()); } else if crate::story_editor::visible(app) { // The editor is rendered after the dock; block its unmeasured first frame conservatively. let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::unique("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect()); blocked.push(rect); } - let t = crate::theme::Tokens::get(ui.ctx()); - let language = app.ui.language.clone(); - let mut draw = |ui: &mut egui::Ui| { - let mut style = craft_ui::docking::DockStyle::from_ui(ui); - style.tab_height = 27.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 = crate::i18n::tr(&language, "Float Panel").to_owned(); - style.close_label = crate::i18n::tr(&language, "Close Panel").to_owned(); - style.move_label = crate::i18n::tr(&language, "Group with").to_owned(); - style.panels_label = crate::i18n::tr(&language, "Panels").to_owned(); - style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned(); - style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned(); - let mut area = craft_ui::docking::DockArea::new(egui::Id::unique("designcraft-panel-docking")).blocked_rects(&blocked); - if let Some(panel) = &raise { - area = area.raise_panel(panel.clone()); + let style = dock_style(ui, &app.ui.language); + let mut dock = craft_ui::docking::DockArea::new(area) + .float_bounds(float_bounds(app, ui.ctx())) + .blocked_rects(&blocked) + .input_blocked(modal) + .auto_collapse_iconic(app.ui.dock_auto_collapse); + if let Some(panel) = raise { + dock = dock.raise_panel(panel); + } + let mut output = dock.show_content(ui, &layout, &style, &mut PanelContent { app: &mut *app }); + for event in std::mem::take(&mut output.events) { + match event { + craft_ui::docking::DockEvent::ToggleAutoCollapse => app.ui.dock_auto_collapse = !app.ui.dock_auto_collapse, + craft_ui::docking::DockEvent::FlyoutChanged { panel } => app.ui.docking_flyout = panel, + _ => {} } - area.show_customized( - ui, - &layout, - &style, - |id| { - crate::i18n::tr( - &language, - crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map(|p| p.1).unwrap_or(id), - ) - .to_owned() - }, - // Close comes from the panel's own tab commands, so the surface doesn't add another. - |_| craft_ui::docking::Permissions { closable: false, ..Default::default() }, - |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, - &mut PanelContent(app), - ) - }; - let output = if layout.root.is_some() { - 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)) - .show(ui, &mut draw) - .inner - } else { - draw(ui) - }; + } if let Some(panel) = output.focused { - app.ui.docking_opened = Some(panel); + if panel != CANVAS { + app.ui.docking_opened = Some(panel.clone()); + } + app.ui.docking_focused = Some(panel); } if let Some(error) = output.error { app.ui.status = error.to_string(); } - if ui.input(|input| input.pointer.any_released()) { - if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { - return app.ui.docking.is_some(); - } - ui.ctx().data_mut(|data| data.remove::(egui::Id::unique("designcraft-panel-docking").with("legacy-origin"))); - } + // The user's own gesture keeps the focus on the panel it moves, while that panel stays open. + let focused = app.ui.docking_focused.clone(); for action in output.actions { if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { app.ui.status = error; } } - icon_rail(app, ui); - true + if let Some(panel) = focused.filter(|panel| panel == CANVAS || layout_of(app).contains(panel)) { + app.ui.docking_focused = Some(panel); + } + #[cfg(not(target_arch = "wasm32"))] + open_window_drops(app, &output.window_files); + // Keys pressed in a floating panel's own OS window run the main window's shortcuts. + crate::menus::forwarded_shortcuts(app, ui.ctx(), output.forwarded_events, output.forwarded_modifiers.unwrap_or_default()); } -fn icon_rail(app: &mut DesignApp, 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::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { - if app.ui.docking.as_ref().is_some_and(|layout| { - layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels.iter().any(|p| p == id))) - }) { - continue; - } - - let response = - crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag()); - if response.clicked() { - let _ = app.run("window.panel.activate", json!({"panel":id})); - } - legacy_tab(app, ui, id, &response); - } - }); - }) - .response - .rect; - let _ = column; -} - -pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { - let Some(panel) = normalize(panel) else { return }; - response.context_menu(|ui| panel_menu(app, ui, panel)); - 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::unique("designcraft-panel-docking"), id.to_string(), source) - { - ui.ctx().data_mut(|data| { - data.insert_temp( - egui::Id::unique("designcraft-panel-docking").with("legacy-origin"), - LegacyDrag { - layout: app.ui.docking.clone(), - open_panel: app.ui.open_panel.clone(), - floating: app.ui.floating.clone(), - generation: app.ui.docking_generation, - }, - ) - }); - app.ui.docking = Some(layout); - app.ui.open_panel = None; - } +/// Files dropped on a floating panel's own OS window open or place as drops on the main window +/// do. The pointer there is in that window's coordinates, so a placed file gets no canvas point. +#[cfg(not(target_arch = "wasm32"))] +fn open_window_drops(app: &mut DesignApp, windows: &[craft_ui::docking::WindowFiles]) { + for window in windows { + app.open_dropped_files(&window.dropped, None); } } -/// The menu behind a panel's three-line button, in either dock: the panel's own commands, then -/// its docking commands. -pub(crate) fn three_line_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { +/// Whether a modal (a dialog, About or Quick Apply) is open. Floating panels in their own OS +/// windows take no input then, as the main window behind the modal doesn't. +pub(crate) fn modal_open(app: &DesignApp) -> bool { + app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() +} + +/// What the control channel reports: the layout in use (craft-ui's serialized `Layout`), +/// whether it is the workspace's default, the panel (or `canvas`) last clicked or selected, the +/// panels closed with a remembered place, the panel whose flyout is open, and where each +/// docking gesture starts in the last frame (`hitRects`). +pub(crate) fn inspect(app: &DesignApp, ctx: &egui::Context) -> Value { + let hits = craft_ui::docking::DockArea::::hit_rects(ctx, area_id()); + let hit_rects: Vec = hits.iter().map(|(hit, r)| json!({"hit": hit, "rect": [r.left(), r.top(), r.width(), r.height()]})).collect(); + json!({ + "layout": layout_of(app), + "customized": app.ui.docking.is_some(), + "focused": app.ui.docking_focused, + "flyout": app.ui.docking_flyout, + "autoCollapse": app.ui.dock_auto_collapse, + "rightToLeft": crate::i18n::is_rtl(&app.ui.language), + "hidden": app.ui.docking_hidden.keys().collect::>(), + "hitRects": hit_rects, + }) +} + +fn dock_style(ui: &egui::Ui, language: &str) -> craft_ui::docking::DockStyle { + let t = crate::theme::Tokens::get(ui.ctx()); + let mut style = craft_ui::docking::DockStyle::from_ui(ui); + style.tab_height = 27.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.blocked_dim = t.panel.gamma_multiply(0.6); + style.right_to_left = crate::i18n::is_rtl(language); + // InDesign's docking: new docks at the docked area's free sides, but no full-height column + // from the window's left or right edge; a drop on no target (the canvas) floats, and a + // floating window's title bar docks the whole window. No Ctrl/Cmd undock latch or hint, no + // Maximize Frame or Close Others items, no wheel or middle-click tab gestures. + style.side_edge_zones = false; + style.new_dock_zones = true; + style.float_on_empty_drop = true; + style.title_bar_docks = true; + style.command_press_floats = false; + style.float_hint = false; + style.close_others_item = false; + style.maximize_item = false; + style.wheel_selects_tabs = false; + style.middle_click_closes_tabs = false; + style.localized(|key| DOCK_LABELS.iter().find(|(k, _)| *k == key).map(|(_, english)| crate::i18n::tr(language, english).to_owned())) +} + +/// DesignCraft's wording of craft-ui's docking labels, translated through `i18n::tr`. +pub(crate) const DOCK_LABELS: &[(&str, &str)] = &[ + ("dock.float", "Float Panel"), + ("dock.close", "Close"), + ("dock.movePanelTo", "Group with"), + ("dock.panels", "Panels"), + ("dock.resize", "Resize panels"), + ("dock.resizeWindow", "Resize panel window"), + ("dock.moveGroup", "Move tab group"), + ("dock.closeGroup", "Close Tab Group"), + ("dock.minimize", "Minimize"), + ("dock.expand", "Expand"), + ("dock.floatGroup", "Float Tab Group"), + ("dock.moveGroupTo", "Group Tab Group with"), + ("dock.moveColumn", "Move panel column"), + ("dock.collapseToIcons", "Collapse to Icons"), + ("dock.expandPanels", "Expand Panels"), + ("dock.collapseColumn", "Collapse to Icons"), + ("dock.expandColumn", "Expand Panels"), + ("dock.moveWindow", "Move panel window"), + ("dock.closeWindow", "Close panel window"), + ("dock.closeOthers", "Close Other Panels in Group"), + ("dock.maximize", "Maximize Frame"), + ("dock.restoreFrame", "Restore Frame Size"), + ("dock.autoCollapse", "Auto-Collapse Iconic Panels"), +]; + +/// The menu behind a panel's three-line button: the panel's own commands, then its docking +/// commands. +fn three_line_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { if panel == "pages" { crate::panels::pages::panel_menu(app, ui); ui.separator(); @@ -634,6 +1218,7 @@ pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { } ui.close(); } + dock_panel_item(app, ui, panel); ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { @@ -652,34 +1237,89 @@ pub(crate) fn panel_menu(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { }); } -struct PanelContent<'a>(&'a mut DesignApp); +/// Dock Panel, for a floating panel: back to where it floated from (`Transaction::dock_back`), +/// else into the dock. +fn dock_panel_item(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str) { + if !layout_of(app).floating.iter().any(|w| w.contains(&panel.to_owned())) { + return; + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Dock Panel"))).clicked() { + if let Err(error) = app.run("window.panel.dock", json!({"panel": panel})) { + app.status(error); + } + ui.close(); + } +} + +/// The size a panel floats at (tab strip included), whatever the size of the dock it left. +fn floating_size(panel: &str) -> egui::Vec2 { + match panel { + "properties" | "pages" | "layers" => egui::vec2(300.0, 420.0), + "characterStyles" | "paragraphStyles" => egui::vec2(290.0, 340.0), + "glyphs" | "library" | "links" | "preflight" | "dataMerge" => egui::vec2(320.0, 400.0), + _ => egui::vec2(280.0, 360.0), + } +} + +/// The name of a dock panel's icon in [`crate::icons`]. +fn panel_icon(panel: &str) -> Option<&'static str> { + crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|(id, _, _)| *id == panel).map(|(_, _, icon)| *icon) +} + +/// The panels' content and icons. +struct PanelContent<'a> { + app: &'a mut DesignApp, +} impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, id: &String) { + if id == CANVAS { + let fill = crate::theme::Tokens::get(ui.ctx()).pasteboard; + ui.painter().rect_filled(ui.max_rect(), 0.0, fill); + self.app.canvas_area(ui, false); + return; + } egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { if crate::dock::scrolls_itself(id) { - crate::dock::panel_body(self.0, ui, id); + crate::dock::panel_body(self.app, ui, id); return; } + // Shrinking to the content's height lets craft-ui fit a float or flyout to it. egui::ScrollArea::vertical() .id_salt(("shared-panel-body", id)) - .auto_shrink([false, false]) - .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); + .auto_shrink([false, true]) + .show(ui, |ui| crate::dock::panel_body(self.app, ui, id)); }); } + fn logical_label(&self, id: &String) -> String { + let english = crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map_or(id.as_str(), |p| p.1); + crate::i18n::tr(&self.app.ui.language, english).to_owned() + } + fn permissions(&self, id: &String) -> Permissions { + model_permissions(id) + } + fn limits(&self, id: &String) -> craft_ui::docking::PanelLimits { + let min = if id == CANVAS { egui::vec2(200.0, 120.0) } else { egui::vec2(220.0, 80.0) }; + craft_ui::docking::PanelLimits { min, ..Default::default() } + } + fn floating_size(&self, id: &String) -> Option { + Some(floating_size(id)) + } + fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, id: &String, color: egui::Color32) -> bool { + let Some(icon) = panel_icon(id) else { return false }; + crate::icons::paint(painter, rect, icon, color); + true + } fn panel_menu(&mut self, ui: &mut egui::Ui, id: &String) { - // The docking surface adds Float Panel and Group with after these, so a tab's menu lists - // what the classic dock's does. if let Some(panel) = normalize(id) { - tab_commands(self.0, ui, panel); - ui.separator(); + dock_panel_item(self.app, ui, panel); } } - fn group_header_width(&self, _: &[String], _: &String, _: bool) -> f32 { + fn group_header_width(&self, _: &[String], _: &String, _: bool, _: f32) -> f32 { 24.0 } fn group_header(&mut self, ui: &mut egui::Ui, _: &[String], active: &String, _: bool) { if let Some(panel) = normalize(active) { - three_line_button(self.0, ui, panel); + three_line_button(self.app, ui, panel); } } fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { @@ -690,20 +1330,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } -/// Close Tab Group: `panel` and the panels tabbed with it, each restorable from Window as one -/// closed with Close. -fn close_group(app: &mut DesignApp, params: &Value) -> Result { - let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; - let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); - let group = tab_group(&layout, panel); - for p in &group { - change(app, "close", &json!({"panel": p}))?; - } - Ok(json!({"closed": group})) -} - -/// InDesign's commands on a panel tab (and at the end of its panel menu): Close, Close Tab Group, -/// Minimize (an open accordion entry) and Collapse to Icons. +/// InDesign's panel tab commands at the end of a panel's three-line menu: Close, Close Tab Group, +/// Minimize (an open accordion entry) and Collapse to Icons. A tab's own context menu is +/// craft-ui's, which lists the same commands. fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str) { let language = app.ui.language.clone(); let tr = |s: &str| crate::i18n::tr(&language, s).to_owned(); @@ -719,43 +1348,27 @@ fn tab_commands(app: &mut DesignApp, ui: &mut egui::Ui, panel: &'static str) { if ui.button(crate::rtl::widget(ui, tr("Close Tab Group"))).clicked() { run(app, ui, "window.panel.closeGroup", json!({"panel": panel})); } - let layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let layout = layout_of(app); let minimizable = open_in_stack(&layout, panel); if ui.add_enabled(minimizable, egui::Button::new(crate::rtl::widget(ui, tr("Minimize")))).clicked() { run(app, ui, "window.panel.layout", json!({"action": {"SetStackOpen": {"panel": panel, "open": false}}})); } ui.separator(); - if ui.add_enabled(app.ui.dock_expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { + let expanded = dock_columns(&layout).iter().any(|c| !c.iconic); + if ui.add_enabled(expanded, egui::Button::new(crate::rtl::widget(ui, tr("Collapse to Icons")))).clicked() { run(app, ui, "window.collapseToIcons", json!({})); } } /// Close the last explicitly opened panel through the same validated layout transaction. pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { - if app.ui.docking.is_some() { - if let Some(panel) = app.ui.docking_opened.clone() { - change(app, "close", &json!({"panel": panel}))?; - } - } else { - app.ui.open_panel = None; + if let Some(panel) = app.ui.docking_opened.clone() { + shared_command(app, "window.panel.close", &json!({"panel": panel}))?; } + app.ui.open_panel = None; Ok(()) } -// Legacy inspection fields are derived from the shared layout after customization. -fn sync_legacy(app: &mut DesignApp) { - if let Some(layout) = &app.ui.docking { - // Float-only panels aren't in the layout; they keep their place in the list. - let float_only: Vec<(String, [f32; 2])> = app.ui.floating.iter().filter(|(id, _)| crate::dock::float_only(id)).cloned().collect(); - app.ui.floating = layout - .floating - .iter() - .flat_map(|group| group.panels.iter().map(|id| (id.clone(), [group.rect[0], group.rect[1]]))) - .chain(float_only) - .collect(); - } -} - #[cfg(test)] #[path = "panel_docking_tests.rs"] mod tests; diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index f34af3c0c..2732313de 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -13,7 +13,7 @@ fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_owners 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"])); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().flat_map(window_groups).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(); @@ -59,8 +59,7 @@ fn harness(app: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { ready = true; return; } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |_| {}); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); }, app, ); @@ -87,14 +86,14 @@ fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab 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::unique("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); - let destination = - h.ctx.read_response(egui::Id::unique("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + assert!(layout.floating.iter().flat_map(window_groups).any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); + let source = h.ctx.read_response(tab_id("layers")).unwrap().rect.center(); + let destination = h.ctx.read_response(tab_id("properties")).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!(!layout.floating.iter().flat_map(window_groups).any(|g| g.panels.contains(&"layers".into()))); assert!(valid(layout)); + assert_eq!(h.state().ui.docking_focused.as_deref(), Some("layers"), "the user's drag keeps the focus on the panel it moved"); } #[test] @@ -138,16 +137,7 @@ fn capture_custom_panel_docking_visual_fixtures() { ready = true; return; } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |ui| { - ui.label("Custom panel workspace"); - if app.ui.language == "ar" { - ui.label("Shaped action references:"); - for action in ["Float Panel", "Group with", "Close Panel"] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, action)); - } - } - }); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| crate::panel_docking::show(app, ui)); }, app, ); @@ -160,7 +150,7 @@ fn capture_custom_panel_docking_visual_fixtures() { .save(directory.join(format!("designcraft-shared-docking-{}-{language}-{width}-{scale}x.png", theme.id()))) .unwrap(); if language == "ar" { - let tab = egui::Id::unique("designcraft-panel-docking").with(("tab", &"swatches".to_string())); + let tab = tab_id("swatches"); let at = harness.ctx.read_response(tab).unwrap().rect.center(); harness.input_mut().events.extend([ Event::PointerMoved(at), @@ -176,10 +166,7 @@ fn capture_custom_panel_docking_visual_fixtures() { harness.run_steps(2); assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open"); for action in ["Float Panel", "Group with", "Close Panel"] { - assert!( - harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 2, - "both logical reference and actual menu label must exist" - ); + assert!(harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 1, "the menu lists {action}"); } harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.png", theme.id()))).unwrap(); } @@ -189,7 +176,7 @@ fn capture_custom_panel_docking_visual_fixtures() { } #[test] -fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { +fn escape_cancels_a_tab_drag_without_customizing_the_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)); @@ -198,7 +185,6 @@ fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { 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()); @@ -218,7 +204,7 @@ fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejecti 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, + app.ui.docking.as_ref().unwrap().floating.iter().find(|window| window.panels().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"}}}}})) @@ -236,27 +222,26 @@ fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejecti assert_eq!(app.ui.docking, before); assert_eq!(app.ui.docking_hidden, hidden); } + let stack = Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }; app.ui.docking = Some(Layout { - root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }), + root: Some(Node::Split { + axis: craft_ui::layout::SplitAxis::Horizontal, + size: craft_ui::layout::SplitSize::Ratio(0.7), + first: Box::new(Node::Tabs { panels: vec![CANVAS.into()], active: 0 }), + second: Box::new(stack), + }), 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") }; + let Some(Node::Split { second, .. }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected split") }; + let Node::Stack { entries } = &**second else { panic!("expected stack") }; assert!(!entries[0].open); assert_eq!(entries[0].height, Some(150.0)); } fn group_members_for_test(layout: &Layout, panel: &str) -> Vec { - 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() + tab_group(layout, panel) } #[test] @@ -292,8 +277,8 @@ fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { std::fs::create_dir_all(directory).unwrap(); h.render().unwrap().save(directory.join("designcraft-populated-stroke-floating.png")).unwrap(); } - let from = h.ctx.read_response(egui::Id::unique("designcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); - let to = h.ctx.read_response(egui::Id::unique("designcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + let from = h.ctx.read_response(tab_id("stroke")).unwrap().rect.center(); + let to = h.ctx.read_response(tab_id("properties")).unwrap().rect.center(); drag(&mut h, from, to); assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); @@ -310,7 +295,7 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); // Floating Layers leaves Pages the active tab beside Properties. app.run("window.panel", json!({"panel":"properties"})).unwrap(); - assert!(!app.ui.docking.as_ref().unwrap().contains(&"stroke".into())); + assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "stroke")); let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( |ui, app: &mut DesignApp| { app.logic(ui.ctx()); @@ -327,7 +312,10 @@ fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { h.run_steps(1); } h.run_steps(2); - assert!(h.state().ui.docking.as_ref().unwrap().contains(&"stroke".into())); + // Stroke stays in the iconic column and opens as its flyout. + assert!(in_iconic_column(h.state().ui.docking.as_ref().unwrap(), "stroke")); + assert_eq!(h.state().ui.docking_flyout.as_deref(), Some("stroke")); + assert!(showing(h.state(), "stroke")); assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); } @@ -356,7 +344,7 @@ fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { } #[test] -fn first_customization_preserves_the_visible_legacy_flyout() { +fn first_customization_keeps_the_panel_the_workspace_opened() { let mut app = app(); app.ui.open_panel = Some("paragraphStyles".into()); app.ui.dock_expanded = false; @@ -387,7 +375,7 @@ fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() let floating = h.state().ui.floating.clone(); // The app calls this before dock rendering when panels are hidden or Presentation starts. let ctx = h.ctx.clone(); - cancel_gesture(h.state_mut(), &ctx); + cancel_gesture(&ctx); assert_eq!(h.state().ui.docking, destination); assert_eq!(h.state().ui.open_panel, open_panel); assert_eq!(h.state().ui.floating, floating); @@ -397,17 +385,20 @@ fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() } #[test] -fn legacy_table_and_control_visibility_work_after_customization_without_document_edits() { +fn table_and_control_visibility_work_after_customization_without_document_edits() { let mut app = app(); app.run("file.new", json!({})).unwrap(); let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); let undo = app.session.active().unwrap().history.undo.len(); app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); app.run("app.tablePanel", json!({})).unwrap(); - assert!(app.ui.docking.as_ref().unwrap().contains(&"table".into())); + assert!(showing(&app, "table")); + // The Table flyout closes; Table stays in its icon group. app.run("app.tablePanel", json!({})).unwrap(); - assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); + assert!(!showing(&app, "table")); + assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "table")); app.run("app.tablePanel", json!({})).unwrap(); + assert!(showing(&app, "table")); let ctx = egui::Context::default(); let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"closePanel":true,"dockExpanded":false})); let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected synchronous control result") }; @@ -415,8 +406,10 @@ fn legacy_table_and_control_visibility_work_after_customization_without_document assert!(!app.ui.dock_expanded); assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); app.run("window.panel", json!({"panel":"properties"})).unwrap(); - assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_flyout.as_deref(), Some("properties"), "it opens as a flyout from the collapsed dock"); assert_eq!(app.ui.docking_raise.as_deref(), Some("properties")); + set_iconic(&mut app, false).unwrap(); + assert_eq!(app.ui.docking_flyout, None, "expanding the column closes its flyout"); assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc); assert_eq!(app.session.active().unwrap().history.undo.len(), undo); } @@ -449,15 +442,14 @@ fn modal_overlay_blocks_a_real_tab_drag_release() { let mut app = app(); app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); let mut h = harness(app, vec2(1280.0, 800.0)); - let area = egui::Id::unique("designcraft-panel-docking"); - let source = h.ctx.read_response(area.with(("tab", &"properties".to_string()))).unwrap().rect.center(); + let source = h.ctx.read_response(tab_id("properties")).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_eq!(h.ctx.dragged_id(), Some(area.with(("tab", &"properties".to_string()))), "test must start the owned tab gesture"); + assert_eq!(h.ctx.dragged_id(), Some(tab_id("properties")), "test must start the owned tab gesture"); let before = h.state().ui.docking.clone(); h.state_mut().ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); let destination = egui::pos2(150.0, 150.0); @@ -484,7 +476,8 @@ fn shift_f9_toggles_table_after_customization_through_the_real_shortcut_path() { h.run_steps(1); h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: false, repeat: false, modifiers }); h.run_steps(2); - assert_eq!(h.state().app.ui.docking.as_ref().unwrap().contains(&"table".into()), visible); + assert_eq!(showing(&h.state().app, "table"), visible); + assert!(h.state().app.ui.docking.as_ref().unwrap().contains(&"table".into()), "Table stays in its icon group"); } assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), before); } @@ -499,7 +492,7 @@ fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); - let tab = egui::Id::unique("designcraft-panel-docking").with(("tab", &"properties".to_string())); + let tab = tab_id("properties"); let from = h.ctx.read_response(tab).unwrap().rect.center(); h.event(Event::PointerMoved(from)); h.run_steps(1); @@ -532,7 +525,7 @@ fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { .find(|p| !editor.contains(*p)) .expect("fixture has space outside editor"); let before_outside = h.state().app.ui.docking.clone(); - let tab = egui::Id::unique("designcraft-panel-docking").with(("tab", &"layers".to_string())); + let tab = tab_id("layers"); let from = h.ctx.read_response(tab).unwrap().rect.center(); h.event(Event::PointerMoved(from)); h.run_steps(1); @@ -553,89 +546,7 @@ fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { } #[test] -fn capture_legacy_panel_menu_visual_fixture() { - 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(); - let mut app = app(); - app.run("file.new", json!({})).unwrap(); - app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Legacy Arabic menu fixture"})).unwrap(); - app.run("app.language", json!({"lang":"ar"})).unwrap(); - let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); - let mut ready = false; - let mut h = Harness::builder().with_size(vec2(800.0, 800.0)).wgpu().build_ui_state( - move |ui, app: &mut DesignApp| { - if !ready { - crate::theme::install_fonts(ui.ctx(), &app.ui.language); - crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::Dark)); - ready = true; - return; - } - crate::dock::show(app, ui); - egui::CentralPanel::default().show(ui, |ui| { - ui.label("Shaped legacy menu references:"); - for label in ["Float Panel", "Layers"] { - crate::rtl::label(ui, crate::i18n::tr("ar", label)); - } - }); - }, - app, - ); - h.input_mut().max_texture_side = Some(8192); - h.run_steps(5); - assert!(h.state().ui.docking.is_none(), "fixture must use the actual legacy menu before customization"); - let at = h.get_by_label("Properties").rect().center(); - h.input_mut().events.extend([ - Event::PointerMoved(at), - Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, - ]); - h.run_steps(1); - h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); - h.run_steps(2); - assert!(egui::Popup::is_any_open(&h.ctx)); - assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Float Panel")).count(), 2, "reference and actual logical action must be accessible"); - h.render().unwrap().save(directory.join("designcraft-legacy-context-dark-ar.png")).unwrap(); - let group = h.get_by_label_contains(crate::i18n::tr("ar", "Group with")).rect().center(); - h.hover_at(group); - h.run_steps(4); - assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Layers")).count(), 2, "reference and actual target must be accessible"); - h.render().unwrap().save(directory.join("designcraft-legacy-targets-dark-ar.png")).unwrap(); - assert!(h.state().ui.docking.is_none()); - let (last, last_label, _) = crate::dock::ICON_PANELS.last().unwrap(); - let logical = crate::i18n::tr("ar", last_label); - let scroll_at = h.query_all_by_label(crate::i18n::tr("ar", "Layers")).find(|node| node.rect().left() > 500.0).unwrap().rect().center(); - let submenu_layer = h.ctx.layer_id_at(scroll_at).unwrap(); - let submenu = h.ctx.memory(|m| m.area_rect(submenu_layer.id)).unwrap(); - let before = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).map(|node| node.rect()); - assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target"); - h.input_mut().events.extend([ - Event::PointerMoved(scroll_at), - Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, -1600.0), - phase: egui::TouchPhase::Move, - modifiers: Modifiers::NONE, - source: egui::MouseWheelSource::Unknown, - }, - ]); - h.run_steps(12); - let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect(); - assert!(submenu.contains_rect(target), "last target must be inside the actual submenu after scrolling"); - assert!(h.ctx.content_rect().contains_rect(target), "last target must be physically visible after wheel scrolling"); - h.render().unwrap().save(directory.join("designcraft-legacy-targets-scrolled-dark-ar.png")).unwrap(); - h.query_all_by_label(logical).find(|node| submenu.contains_rect(node.rect())).unwrap().click(); - h.run_steps(4); - let layout = h.state().ui.docking.as_ref().unwrap(); - assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == "properties").count(), 1); - assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == *last).count(), 1); - let group = group_members_for_test(layout, last); - assert!(group.iter().any(|panel| panel == "properties")); - assert_eq!(group.last().map(String::as_str), Some("properties"), "clicked destination must receive the original panel after it"); - assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); - h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); -} - -#[test] -fn legacy_dock_menu_button_opens_the_active_panel_menu() { +fn dock_menu_button_opens_the_active_panel_menu() { let mut app = app(); app.run("file.new", json!({})).unwrap(); app.ui.dock_tab = "pages".into(); @@ -643,8 +554,8 @@ fn legacy_dock_menu_button_opens_the_active_panel_menu() { let mut h = harness(app, vec2(1280.0, 800.0)); assert!(h.state().ui.docking.is_none()); let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - let dock = egui::containers::panel::PanelState::load(&h.ctx, h.ui_id().with("dock")).unwrap().outer_rect; - let at = egui::pos2(dock.right() - 15.0, tab.center().y); + // The outer dock column is at the window's right edge; the ≡ ends its tab strip. + let at = egui::pos2(1280.0 - 12.0, tab.center().y); h.event(Event::PointerMoved(at)); h.run_steps(1); for pressed in [true, false] { @@ -689,9 +600,18 @@ fn collapse_to_icons_shrinks_the_dock_to_its_icons() { assert!(app.ui.dock_expanded); app.run("window.collapseToIcons", json!({})).unwrap(); assert!(!app.ui.dock_expanded); - // A panel chosen from Window opens the dock again. + // A panel chosen from Window opens as a flyout beside its icon; the dock stays collapsed. app.run("window.panel", json!({"panel":"pages"})).unwrap(); - assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_flyout.as_deref(), Some("pages")); + assert_eq!(app.ui.docking_raise.as_deref(), Some("pages")); + assert!(showing(&app, "pages")); + assert!(in_iconic_column(app.ui.docking.as_ref().unwrap(), "pages")); + // Choosing it again closes the flyout, and a third time opens it again. + app.run("window.panel", json!({"panel":"pages"})).unwrap(); + assert_eq!(app.ui.docking_flyout, None); + assert!(!showing(&app, "pages")); + app.run("window.panel", json!({"panel":"pages"})).unwrap(); + assert_eq!(app.ui.docking_flyout.as_deref(), Some("pages")); } #[test] @@ -704,14 +624,40 @@ fn a_tabs_context_menu_lists_indesigns_tab_commands_and_close_works() { h.run_steps(1); h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(2); + // The column bar's » button is also labelled Collapse to Icons. for item in ["Close", "Close Tab Group", "Minimize", "Collapse to Icons", "Float Panel"] { - assert!(h.query_by_label(item).is_some(), "{item}"); + assert!(h.query_all_by_label(item).next().is_some(), "{item}"); } + assert_eq!(h.query_all_by_label("Minimize").count(), 1, "the tab menu lists Minimize once"); + assert!(h.query_all_by_label("Dock Panel").next().is_none(), "a docked panel has no Dock Panel"); h.get_by_label("Close").click(); h.run_steps(2); assert!(!h.state().ui.docking.as_ref().unwrap().contains(&"layers".to_string())); } +/// A floating panel's tab menu and three-line menu have Dock Panel, which puts it back where it +/// floated from. +#[test] +fn dock_panel_in_a_floating_panels_menus_returns_it_to_the_dock() { + let mut app = app(); + app.run("window.panel.activate", json!({"panel":"layers"})).unwrap(); + let docked = app.ui.docking.clone().unwrap(); + app.run("window.panel.float", json!({"panel":"layers","x":200,"y":150})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let tab = h.ctx.read_response(tab_id("layers")).unwrap().rect; + click(&mut h, tab.center(), PointerButton::Secondary); + h.get_by_label("Dock Panel").click(); + h.run_steps(3); + assert_eq!(h.state().ui.docking.as_ref(), Some(&docked), "the tab menu docks it back in its group"); + let mut app = h.into_state(); + app.run("window.panel.float", json!({"panel":"layers","x":200,"y":150})).unwrap(); + let mut menu = Harness::builder().with_size(vec2(300.0, 400.0)).build_ui_state(|ui, app: &mut DesignApp| panel_menu(app, ui, "layers"), app); + menu.run_steps(1); + menu.get_by_label("Dock Panel").click(); + menu.run_steps(2); + assert_eq!(menu.state().ui.docking.as_ref(), Some(&docked), "so does the three-line menu"); +} + /// Window › Styles › Paragraph Styles opens InDesign's floating Character Styles | Paragraph Styles /// group, which stays open while other panels open (Learn Skills Daily's InDesign course, 7:55). #[test] @@ -719,24 +665,49 @@ fn paragraph_styles_open_floating_with_character_styles() { let mut app = app(); app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); + let group = layout.floating.iter().flat_map(window_groups).find(|g| g.panels.iter().any(|p| p == "paragraphStyles")).expect("a floating group"); assert_eq!(group.panels, ["characterStyles", "paragraphStyles"]); assert_eq!(group.panels[group.active], "paragraphStyles", "the one chosen is in front"); // The dock still shows Properties in front. - assert!(matches!(&layout.root, Some(craft_ui::docking::Node::Tabs { panels, active }) if panels[*active] == "properties"), "{:?}", layout.root); + assert_eq!(layout.panel_state(&"properties".to_string()), craft_ui::docking::PanelState::Front, "{:?}", layout.root); + assert_eq!(layout.floating.len(), 1, "one floating window holds both tabs"); // Other panels from Window leave it open. app.run("window.panel", json!({"panel":"color"})).unwrap(); app.run("window.panel", json!({"panel":"links"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - assert!(layout.floating.iter().any(|g| g.panels == ["characterStyles", "paragraphStyles"])); + assert!(layout.floating.iter().flat_map(window_groups).any(|g| g.panels == ["characterStyles", "paragraphStyles"])); // Once showing, Window › Character Styles brings that tab forward. app.run("window.panel", json!({"panel":"characterStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); - let group = layout.floating.iter().find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); + let group = layout.floating.iter().flat_map(window_groups).find(|g| g.panels.iter().any(|p| p == "characterStyles")).unwrap(); assert_eq!(group.panels[group.active], "characterStyles"); assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "characterStyles").count(), 1); } +/// Window › Styles floats the styles icon group out of the iconic column; Dock Panel puts the +/// group back as one, at the same place in that column. +#[test] +fn dock_panel_returns_the_floated_styles_group_to_its_icon_group() { + let mut app = app(); + let docked = app.ui.docking.clone().unwrap_or_else(|| default_layout(&app.ui)); + let styles = ["characterStyles", "paragraphStyles"].map(String::from).to_vec(); + let icon_groups = |layout: &Layout| -> Vec> { + dock_columns(layout).into_iter().filter(|c| c.iconic).flat_map(|c| &c.groups).map(|g| g.panels.clone()).collect() + }; + let before = icon_groups(&docked); + let at = before.iter().position(|g| *g == styles).expect("the styles are an icon group"); + app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.iter().flat_map(window_groups).any(|g| g.panels == styles), "{:?}", layout.floating); + assert!(!icon_groups(&layout).contains(&styles)); + app.run("window.panel.dock", json!({"panel":"paragraphStyles"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.is_empty(), "{:?}", layout.floating); + assert_eq!(icon_groups(&layout), before, "both styles are back in their icon group, at the same place"); + assert_eq!(icon_groups(&layout).get(at), Some(&styles)); + assert!(valid(&layout)); +} + /// Style panels the user docked and then closed come back in the dock, not floating. #[test] fn paragraph_styles_closed_from_the_dock_reopen_there() { @@ -751,7 +722,7 @@ fn paragraph_styles_closed_from_the_dock_reopen_there() { app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); let layout = app.ui.docking.clone().unwrap(); assert!(layout.contains(&"paragraphStyles".to_string())); - assert!(layout.floating.iter().all(|g| !g.panels.iter().any(|p| p == "paragraphStyles")), "{:?}", layout.floating); + assert!(layout.floating.iter().flat_map(window_groups).all(|g| !g.panels.iter().any(|p| p == "paragraphStyles")), "{:?}", layout.floating); } fn click(h: &mut Harness<'static, DesignApp>, at: Pos2, button: PointerButton) { @@ -765,7 +736,7 @@ fn click(h: &mut Harness<'static, DesignApp>, at: Pos2, button: PointerButton) { } /// Window › Paragraph Styles switches to the docking layout. After Window › Pages the Pages panel -/// keeps its three-line panel menu, and its tab menu lists what the classic dock's does. +/// keeps its three-line panel menu, and its tab menu lists InDesign's tab commands. #[test] fn pages_keeps_its_panel_menu_in_the_docking_layout() { let mut app = app(); @@ -775,8 +746,7 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() { assert!(app.ui.docking.is_some()); let mut h = harness(app, vec2(1280.0, 800.0)); let tab = h.query_all_by_label("Pages").find(|node| node.rect().height() <= 45.0).unwrap().rect(); - let dock = egui::containers::panel::PanelState::load(&h.ctx, h.ui_id().with("shared-panel-dock")).unwrap().outer_rect; - click(&mut h, egui::pos2(dock.right() - 12.0, tab.center().y), PointerButton::Primary); + click(&mut h, egui::pos2(1280.0 - 12.0, tab.center().y), PointerButton::Primary); assert!(egui::Popup::is_any_open(&h.ctx), "the three-line button must open a menu"); for item in ["Numbering & Section Options…", "Close Tab Group", "Float Panel"] { assert!(h.query_by_label(item).is_some(), "{item}"); @@ -794,3 +764,514 @@ fn pages_keeps_its_panel_menu_in_the_docking_layout() { assert!(h.query_by_label("Close Panel").is_none()); assert!(h.query_by_label("Panel Options…").is_none(), "the panel's own commands stay in its panel menu"); } + +fn group_with(layout: &Layout, panel: &str) -> (bool, Vec, usize) { + if let Some(g) = layout.floating.iter().flat_map(window_groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return (true, g.panels, g.active); + } + let mut visit: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = visit.pop() { + match node { + Node::Split { first, second, .. } => visit.extend([&**first, &**second]), + Node::Tabs { panels, active } if panels.iter().any(|p| p == panel) => return (false, panels.clone(), *active), + Node::Dock { columns, .. } => { + if let Some(g) = columns.iter().flat_map(|c| &c.groups).find(|g| g.panels.iter().any(|p| p == panel)) { + return (false, g.panels.clone(), g.active); + } + } + _ => {} + } + } + panic!("{panel} is not in {layout:?}"); +} + +fn drag_bar(h: &Harness<'static, DesignApp>, first: &str) -> egui::Rect { + h.ctx.read_response(hit_id(&craft_ui::docking::Hit::DragBar(first.to_string()))).expect("the group's drag bar").rect +} + +/// The docked Character Styles | Paragraph Styles group moves as one by its drag bar: dropped on +/// the canvas it floats, dropped on another strip it joins as tabs. Order and the tab in front +/// are kept. +#[test] +fn dragging_a_docked_group_by_its_bar_moves_every_tab() { + let mut app = app(); + app.run("window.panel", json!({"panel":"paragraphStyles"})).unwrap(); + let below = json!({"MoveGroup": {"panel": "characterStyles", "to": {"At": {"anchor": "properties", "placement": {"Group": {"below": true}}}}}}); + app.run("window.panel.layout", json!({"action": below})).unwrap(); + let styles = ["characterStyles", "paragraphStyles"]; + assert_eq!(group_with(app.ui.docking.as_ref().unwrap(), "characterStyles"), (false, styles.map(String::from).to_vec(), 1)); + + let mut h = harness(app, vec2(1280.0, 800.0)); + let bar = drag_bar(&h, "characterStyles"); + drag(&mut h, bar.center(), egui::pos2(400.0, 400.0)); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert_eq!(group_with(layout, "paragraphStyles"), (true, styles.map(String::from).to_vec(), 1), "{layout:?}"); + assert!(valid(layout)); + + let bar = drag_bar(&h, "characterStyles"); + let properties = h.ctx.read_response(tab_id("properties")).unwrap().rect.center(); + drag(&mut h, bar.center(), properties); + let layout = h.state().ui.docking.as_ref().unwrap(); + let (floating, panels, active) = group_with(layout, "characterStyles"); + assert!(!floating, "{layout:?}"); + let at = panels.iter().position(|p| p == "characterStyles").unwrap(); + assert_eq!(panels.get(at + 1).map(String::as_str), Some("paragraphStyles"), "{panels:?}"); + assert_eq!(panels[active], "paragraphStyles"); + assert!(layout.floating.is_empty(), "{:?}", layout.floating); +} + +fn dock_of(layout: &Layout) -> (Side, &[Column]) { + let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("root is not a split: {layout:?}") }; + assert!(matches!(&**first, Node::Tabs { panels, .. } if panels == &[CANVAS.to_string()]), "{layout:?}"); + let Node::Dock { side, columns } = &**second else { panic!("second is not a dock: {layout:?}") }; + (*side, columns) +} + +#[test] +fn default_workspaces_are_the_canvas_beside_an_end_dock() { + let mut app = app(); + let layout = default_layout(&app.ui); + assert!(valid(&layout)); + let (side, columns) = dock_of(&layout); + assert_eq!(side, Side::End); + let [outer, icons] = columns else { panic!("{columns:?}") }; + assert!(!outer.iconic); + assert_eq!(outer.groups.len(), 1); + assert_eq!(outer.groups[0].panels, ["properties", "pages", "layers"]); + assert!(icons.iconic); + let groups: Vec> = icons.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect(); + let expected: Vec> = ICON_GROUPS.iter().map(|g| g.to_vec()).collect(); + assert_eq!(groups, expected, "the iconic column groups InDesign's panel families"); + // Essentials Classic: only its named icon groups, in one iconic column. + app.run("window.workspace", json!({"name": "Essentials Classic"})).unwrap(); + let layout = default_layout(&app.ui); + assert!(valid(&layout)); + let (_, columns) = dock_of(&layout); + let [column] = columns else { panic!("{columns:?}") }; + assert!(column.iconic); + let expected: Vec> = app.ui.icon_groups.clone().unwrap(); + let groups: Vec> = column.groups.iter().map(|g| g.panels.clone()).collect(); + assert_eq!(groups.len(), expected.len()); + assert!(groups.iter().flatten().all(|p| expected.iter().flatten().any(|e| e == p))); +} + +/// Every dock panel that starts in the iconic column has one place in [`ICON_GROUPS`], so a new +/// panel is not left as a group of its own. +#[test] +fn every_icon_panel_is_in_one_default_icon_group() { + for (id, _, _) in crate::dock::ICON_PANELS { + assert_eq!(ICON_GROUPS.iter().flat_map(|g| g.iter()).filter(|p| *p == id).count(), 1, "{id}"); + } + for id in ICON_GROUPS.iter().flat_map(|g| g.iter()) { + assert!(crate::dock::ICON_PANELS.iter().any(|p| p.0 == *id), "{id} is not a dock panel"); + } +} + +/// A panel the workspace places elsewhere leaves its icon group; a group it empties goes. +#[test] +fn placed_panels_leave_their_icon_groups() { + let mut placed = Placed::default(); + for id in ["properties", "pages", "layers", "swatches", "effects"] { + placed.take(id); + } + let column = icon_column(None, &mut placed).unwrap(); + let groups: Vec> = column.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect(); + assert_eq!(groups.first().unwrap(), &["stroke", "color", "gradient"]); + assert_eq!(groups.get(1).unwrap(), &["characterStyles", "paragraphStyles"]); + assert_eq!(groups.len(), ICON_GROUPS.len() - 1); +} + +#[test] +fn the_dock_is_at_the_right_in_english_and_the_left_in_arabic() { + let tab = |h: &Harness<'static, DesignApp>| h.ctx.read_response(tab_id("properties")).map(|r| r.rect).unwrap(); + let h = harness(app(), vec2(1280.0, 800.0)); + assert!(tab(&h).left() > 640.0, "{:?}", tab(&h)); + let mut arabic = app(); + arabic.ui.language = "ar".into(); + let h = harness(arabic, vec2(1280.0, 800.0)); + assert!(tab(&h).right() < 640.0, "{:?}", tab(&h)); +} + +#[test] +fn ui_docking_reports_the_layout_and_the_focused_panel() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let ctx = h.ctx.clone(); + let docking = |app: &mut DesignApp| { + let (req, _rx) = crate::control::ControlRequest::new("ui.docking", json!({})); + match crate::control::handle(app, &ctx, &req) { + crate::control::Outcome::Done(v) => v, + crate::control::Outcome::Screenshot { .. } => panic!("ui.docking took a screenshot"), + } + }; + let v = docking(h.state_mut()); + assert_eq!(v["ok"], true, "{v}"); + let result = &v["result"]; + assert_eq!(result["customized"], false); + assert_eq!(result["rightToLeft"], false); + assert!(result["focused"].is_null()); + let layout: Layout = serde_json::from_value(result["layout"].clone()).unwrap(); + assert_eq!(layout, default_layout(&h.state().ui)); + let at = h.ctx.read_response(tab_id("layers")).unwrap().rect.center(); + click(&mut h, at, PointerButton::Primary); + let v = docking(h.state_mut()); + assert_eq!(v["result"]["focused"], "layers", "{v}"); + // Closing the focused panel releases the focus. + h.state_mut().run("window.panel.close", json!({"panel": "layers"})).unwrap(); + let v = docking(h.state_mut()); + assert!(v["result"]["focused"].is_null(), "{v}"); + assert_eq!(v["result"]["customized"], true); + assert_eq!(v["result"]["hidden"], json!(["layers"])); +} + +/// A reset replaces the panels: none of them stays focused, opened for `ui.set closePanel` or +/// queued to raise. +#[test] +fn workspace_reset_forgets_the_focused_opened_and_raised_panel() { + let mut app = app(); + app.run("window.panel.float", json!({"panel": "layers"})).unwrap(); + app.ui.docking_focused = Some("layers".into()); + assert_eq!(app.ui.docking_opened.as_deref(), Some("layers")); + assert_eq!(app.ui.docking_raise.as_deref(), Some("layers")); + app.run("window.workspace.reset", json!({})).unwrap(); + assert_eq!(app.ui.docking_focused, None); + assert_eq!(app.ui.docking_opened, None); + assert_eq!(app.ui.docking_raise, None); + close_visible(&mut app).unwrap(); + assert!(layout_of(&app).contains(&"layers".to_string()), "closePanel after a reset closes nothing"); +} + +/// A command that moves the focused panel takes the focus away and leaves it the panel last +/// opened; one that closes it forgets both. +#[test] +fn moving_or_closing_the_focused_panel_by_command_updates_the_panel_state() { + let mut app = app(); + app.ui.docking_focused = Some("layers".into()); + app.run("window.panel.activate", json!({"panel": "pages"})).unwrap(); + assert_eq!(app.ui.docking_focused.as_deref(), Some("layers"), "a command that leaves Layers in place keeps its focus"); + app.ui.docking_opened = Some("layers".into()); + app.run("window.panel.move", json!({"panel": "layers", "anchor": "swatches", "zone": "tab"})).unwrap(); + assert_eq!(app.ui.docking_focused, None); + assert_eq!(app.ui.docking_opened.as_deref(), Some("layers")); + app.ui.docking_focused = Some("layers".into()); + app.run("window.panel.close", json!({"panel": "layers"})).unwrap(); + assert_eq!(app.ui.docking_focused, None); + assert_eq!(app.ui.docking_opened, None); + assert_eq!(app.ui.docking_raise, None); +} + +/// A divider dragged with the pointer gives the keyboard back: the next arrow key nudges the +/// selection and leaves the dock's size alone. +#[test] +fn arrow_after_dragging_a_dock_divider_nudges_the_selection_not_the_dock() { + let mut app = app(); + app.run("file.new", json!({"width":480,"height":360})).unwrap(); + let id = app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + let column = craft_ui::docking::Hit::Divider(craft_ui::docking::Divider::Column("properties".to_string())); + let divider = hits(&h).into_iter().find_map(|(hit, rect)| (hit == column).then_some(rect)).expect("the main dock column's divider"); + drag(&mut h, divider.center(), divider.center() - vec2(40.0, 0.0)); + let resized = h.state().ui.docking.clone().expect("the drag customizes the layout"); + let bounds = |h: &Harness<'static, DesignApp>| h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + let before = bounds(&h); + h.event(Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::Key { key: egui::Key::ArrowRight, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(bounds(&h).x0 > before.x0, "the arrow nudges the selection"); + assert_eq!(h.state().ui.docking.as_ref(), Some(&resized), "the arrow doesn't resize the dock"); +} + +fn hits(h: &Harness<'static, DesignApp>) -> Vec<(craft_ui::docking::Hit, egui::Rect)> { + craft_ui::docking::DockArea::::hit_rects(&h.ctx, area_id()) +} + +/// `window.panel.*` ids and DesignCraft's aliases go through craft-ui's `DockCommand`. +#[test] +fn shared_panel_commands_float_dock_and_reject_atomically() { + let mut app = app(); + let docked = default_layout(&app.ui); + app.run("window.floatPanel", json!({"panel": "Layers", "x": 60, "y": 90})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + let window = layout.floating.iter().find(|w| w.contains(&"layers".to_string())).expect("Layers floats"); + assert_eq!(window.rect, [60.0, 90.0, 300.0, 420.0], "at the given place, at its own size"); + app.run("window.dockPanel", json!({"panel": "layers"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!( + layout.root, + docked.root.map(|mut root| { + // Back in its place, in front of its group. + if let Node::Split { second, .. } = &mut root + && let Node::Dock { columns, .. } = &mut **second + && let Some(group) = columns.first_mut().and_then(|c| c.groups.first_mut()) + { + group.active = 2; + } + root + }), + "Dock Panel returns it where it floated from" + ); + // A docked panel that shares its group leaves it for a new dock at the start edge. + let mut tabbed = self::app(); + tabbed.run("window.panel.dock", json!({"panel": "layers", "side": "Start"})).unwrap(); + let layout = tabbed.ui.docking.clone().unwrap(); + assert_eq!(tab_group(&layout, "pages"), ["properties", "pages"]); + assert!(matches!(&layout.root, Some(Node::Split { first, .. }) if matches!(&**first, Node::Dock { side: Side::Start, .. }))); + assert_eq!(tab_group(&layout, "layers"), ["layers"]); + // A whole group, by any of its panels, then into a new column of the end dock. + app.run("window.panel.float", json!({"panel": "pages", "group": true, "x": 80, "y": 80, "width": 300, "height": 400})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert_eq!(layout.floating.len(), 1); + assert_eq!(window_groups(&layout.floating[0])[0].panels, ["properties", "pages", "layers"]); + app.run("window.panel.dock", json!({"panel": "pages", "group": true, "side": "End"})).unwrap(); + let layout = app.ui.docking.clone().unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!(tab_group(&layout, "pages"), ["properties", "pages", "layers"]); + // A lone panel to a new dock at the start edge. + app.run("window.panel.float", json!({"panel": "stroke"})).unwrap(); + app.run("window.panel.dock", json!({"panel": "stroke", "side": "Start"})).unwrap(); + assert!( + matches!(&app.ui.docking.as_ref().unwrap().root, Some(Node::Split { first, .. }) if matches!(&**first, Node::Dock { side: Side::Start, .. })) + ); + // Docking it at that side again leaves it where it is. + let before = app.ui.docking.clone(); + app.run("window.panel.dock", json!({"panel": "stroke", "side": "Start"})).unwrap(); + assert_eq!(app.ui.docking, before, "it already leads the start dock's last column"); + // Close Others and Minimize are shared commands too. + app.run("window.panel.closeOthers", json!({"panel": "pages"})).unwrap(); + assert_eq!(tab_group(app.ui.docking.as_ref().unwrap(), "pages"), ["pages"]); + assert!(app.ui.docking_hidden.contains_key("properties") && app.ui.docking_hidden.contains_key("layers")); + app.run("window.panel.minimize", json!({"panel": "pages"})).unwrap(); + let location = app.ui.docking.as_ref().unwrap().location(&"pages".to_string()).unwrap(); + assert!(location.group.is_some_and(|g| g.minimized)); + // Rejected commands change nothing. + let before = (app.ui.docking.clone(), app.ui.docking_hidden.clone()); + for (id, params) in [ + ("window.workspace.lock", json!({})), + ("window.panel.window", json!({"panel": "pages"})), + ("window.panel.move", json!({"panel": "stroke", "anchor": "pages", "zone": "left", "before": "pages"})), + ("window.panel.dock", json!({"panel": "stroke", "side": "Middle"})), + ("window.panel.dock", json!({"panel": "stroke", "zone": "left"})), + ("window.dockPanel", json!({"panel": "properties", "zone": "bottom"})), + ] { + assert!(app.run(id, params).is_err(), "{id}"); + assert_eq!((app.ui.docking.clone(), app.ui.docking_hidden.clone()), before, "{id}"); + } + // A zone is a side of an anchor; the error names the two ways to say where. + let error = app.run("window.panel.dock", json!({"panel": "stroke", "zone": "right"})).unwrap_err(); + assert!(error.contains("anchor") && error.contains("side"), "{error}"); + // Every shared id is registered for the command list and MCP. + for id in craft_ui::docking::DockCommand::::IDS { + assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == *id), "{id} is not registered"); + } +} + +/// A tab released over the canvas floats there (craft-ui: the protected canvas takes no tabs or +/// splits); a lone panel released in the window's left edge band makes a new dock there. +#[test] +fn tabs_dropped_on_the_canvas_float_and_at_the_far_edge_make_a_dock() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let tab = |h: &Harness<'static, DesignApp>, panel: &str| { + hits(h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Tab(panel.into())).map(|(_, r)| r) + }; + let layers = tab(&h, "layers").expect("the Layers tab"); + drag(&mut h, layers.center(), egui::pos2(500.0, 400.0)); + let layout = h.state().ui.docking.clone().unwrap(); + assert!(layout.floating.iter().any(|w| w.contains(&"layers".to_string())), "{layout:?}"); + // A new dock takes a panel that is alone in its group: the floating Layers, by its title bar. + // The harness's area starts 8 pt in from the window edge. + let title = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Title("layers".into())).expect("the title bar").1; + drag(&mut h, title.center(), egui::pos2(12.0, 400.0)); + let layout = h.state().ui.docking.clone().unwrap(); + assert!(valid(&layout)); + let mut docks = Vec::new(); + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side, columns } => { + docks.push((*side, columns.iter().flat_map(|c| &c.groups).flat_map(|g| g.panels.clone()).collect::>())) + } + _ => {} + } + } + assert!(docks.iter().any(|(side, panels)| *side == Side::Start && panels == &["layers"]), "{docks:?} {:?}", layout.floating); +} + +/// `ui.docking` lists where each gesture starts: tabs, drag bars and the iconic column's icons. +#[test] +fn ui_docking_reports_hit_rects() { + let h = harness(app(), vec2(1280.0, 800.0)); + let v = crate::panel_docking::inspect(h.state(), &h.ctx); + let rects = v["hitRects"].as_array().expect("hitRects"); + let rect_of = |hit: Value| { + let entry = rects.iter().find(|r| r["hit"] == hit).unwrap_or_else(|| panic!("no {hit} in {rects:?}")); + let r: Vec = serde_json::from_value(entry["rect"].clone()).unwrap(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), vec2(r[2], r[3])) + }; + let tab = rect_of(json!({"Tab": "pages"})); + let bar = rect_of(json!({"DragBar": "properties"})); + let icon = rect_of(json!({"Icon": "stroke"})); + assert!(tab.width() > 20.0 && bar.width() >= 24.0 && icon.width() > 20.0, "{tab:?} {bar:?} {icon:?}"); + assert!((tab.center().y - bar.center().y).abs() < 1.0, "the bar is in the tab strip"); + assert!(icon.left() > 640.0, "the iconic column is at the end edge"); +} + +/// Clicking an icon in the iconic column opens its panel as a flyout on the canvas side; a +/// second click closes it. +#[test] +fn clicking_an_iconic_column_icon_opens_its_flyout() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let icon = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Icon("swatches".into())).expect("the Swatches icon").1; + click(&mut h, icon.center(), PointerButton::Primary); + assert_eq!(h.state().ui.docking_flyout.as_deref(), Some("swatches")); + let tab = hits(&h).into_iter().find(|(hit, _)| *hit == craft_ui::docking::Hit::Tab("swatches".into())).expect("the flyout's tab").1; + assert!(tab.right() <= icon.left() + 1.0, "the flyout is beside the column, on the canvas side: {tab:?} {icon:?}"); + assert!(showing(h.state(), "swatches")); + click(&mut h, icon.center(), PointerButton::Primary); + assert!(h.state().ui.docking_flyout.is_none()); +} + +/// Iconic columns paint DesignCraft's own panel icons, in the colour craft-ui passes; a panel +/// without an icon paints nothing. +#[test] +fn panel_icons_are_painted_in_code() { + let mut app = app(); + let mut content = PanelContent { app: &mut app }; + let ctx = egui::Context::default(); + let mut paint = |panel: &str| { + let mut painted = false; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(20.0, 20.0)); + painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel.to_string(), egui::Color32::RED); + }); + output.textures_delta.clear(); + (painted, output.shapes.len()) + }; + let (painted, shapes) = paint("pages"); + assert!(painted && shapes > 0, "{painted} {shapes}"); + assert_eq!(paint(CANVAS), (false, 0)); +} + +/// Every docking label in DesignCraft's wording is translated in the shared catalog's languages. +#[test] +fn docking_labels_are_translated() { + for language in ["de", "fr", "es", "ja", "zh"] { + for (key, english) in DOCK_LABELS { + assert_ne!(crate::i18n::tr(language, english), *english, "{key} in {language}"); + } + } +} + +/// With DesignCraft's title bar under macOS's transparent one, a floating window saved in the +/// title-bar strip is drawn below it, where its title bar can't move the app window. +#[test] +fn a_floating_window_saved_in_the_title_bar_strip_is_drawn_below_it() { + let mut app = app(); + app.integrated_titlebar = true; + app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); + let window = app.ui.docking.as_mut().unwrap().floating.iter_mut().find(|w| w.panels().any(|p| p == "properties")).unwrap(); + window.rect = [200.0, 0.0, 280.0, 360.0]; + let h = harness(app, egui::vec2(1400.0, 900.0)); + let drawn = h.ctx.memory(|m| m.area_rect(crate::panel_docking::floating_layer("properties").id)).unwrap(); + assert!(drawn.min.y >= crate::chrome::MAC_APP_BAR - 0.5, "{drawn:?}"); +} + +/// A floating window holds splits like the dock: a panel docked below another one in the +/// window splits it, the divider between them drags, and the new size is saved. +#[test] +fn a_split_inside_a_floating_window_resizes_and_is_saved() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"swatches", "x":300, "y":120, "width":300, "height":500})).unwrap(); + app.run("window.panel.move", json!({"panel":"color", "anchor":"swatches", "zone":"bottom"})).unwrap(); + let window = |app: &DesignApp| app.ui.docking.as_ref().unwrap().floating.iter().find(|w| w.contains("swatches")).cloned().unwrap(); + let ratio = |window: &Floating| match window.root { + Node::Split { size: craft_ui::layout::SplitSize::Ratio(r), .. } => r, + ref root => panic!("expected a split window: {root:?}"), + }; + assert_eq!(window_groups(&window(&app)).into_iter().map(|g| g.panels).collect::>(), [["swatches"], ["color"]]); + let before = ratio(&window(&app)); + let mut h = harness(app, vec2(1400.0, 900.0)); + let divider = hits(&h) + .into_iter() + .find_map(|(hit, rect)| { + matches!(&hit, craft_ui::docking::Hit::Divider(craft_ui::docking::Divider::WindowSplit(_, path)) if path.is_empty()).then_some(rect) + }) + .expect("the window's divider"); + drag(&mut h, divider.center(), divider.center() + vec2(0.0, 60.0)); + let after = ratio(&window(h.state())); + assert!(after > before + 0.05, "{before} -> {after}"); + let saved = serde_json::to_vec(&h.state().ui).unwrap(); + let loaded = crate::UiState::from_preferences_json(&saved).unwrap(); + assert_eq!(loaded.docking, h.state().ui.docking); +} + +/// The default layout is rebuilt every frame: its floating windows keep their ids, so their +/// native windows aren't opened again each frame. +#[test] +fn default_layout_floats_keep_their_window_ids() { + let mut app = app(); + app.ui.floating = vec![("swatches".to_string(), [10.0, 20.0])]; + let ids = |app: &DesignApp| default_layout(&app.ui).floating.iter().map(|w| w.id).collect::>(); + let first = ids(&app); + assert_eq!(first.len(), 1, "Swatches floats"); + assert_eq!(ids(&app), first); +} + +#[test] +fn dialogs_about_and_quick_apply_block_native_floating_windows() { + let mut app = app(); + assert!(!modal_open(&app)); + app.ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + assert!(modal_open(&app), "dialog"); + app.ui.dialog = None; + app.run("help.about", json!({})).unwrap(); + assert!(modal_open(&app), "About"); + app.run("help.about", json!({"open": false})).unwrap(); + assert!(!modal_open(&app)); + app.ui.palette = Some(String::new()); + assert!(modal_open(&app), "Quick Apply"); + app.ui.palette = None; + assert!(!modal_open(&app)); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_document_dropped_on_a_floating_panel_window_opens() { + #[derive(Debug)] + struct Dropped(std::path::PathBuf); + impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dropped.designcraft"); + let mut saved = app(); + saved.run("file.new", json!({})).unwrap(); + saved.run("file.save", json!({"path": path})).unwrap(); + + let mut app = app(); + assert!(app.session.documents().is_empty()); + let files = craft_ui::docking::WindowFiles { + window: 1, + viewport: egui::ViewportId::from_hash_of("float"), + panel: "layers".to_owned(), + pos: None, + hovered: Vec::new(), + dropped: vec![std::sync::Arc::new(Dropped(path.clone())) as egui::DroppedFileHandle], + }; + open_window_drops(&mut app, &[files]); + assert_eq!(app.session.documents().len(), 1, "the dropped document opens"); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b7085b050..8b12d9961 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -797,6 +797,15 @@ mod font_menu_ui_tests { for k in 0..count { h.state_mut().app.run("type.char", json!({"attrs": {"fontFamily": designcraft_fonts::DEFAULT_FAMILY}})).unwrap(); h.run_steps(4); + // A short Properties group (below it, the workspace's open panel) scrolls to the field. + let mut nodes: Vec<_> = h + .query_all_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(designcraft_fonts::DEFAULT_FAMILY)) + .collect(); + nodes.sort_by(|a, b| a.rect().min.y.total_cmp(&b.rect().min.y).then(a.rect().min.x.total_cmp(&b.rect().min.x))); + if let Some(node) = nodes.get(k) { + node.scroll_to_me(); + } + h.run_steps(4); let at = fields(&h, designcraft_fonts::DEFAULT_FAMILY)[k]; search_in_menu(&mut h, at, &format!("{name}, field {k}")); assert_eq!(family(&mut h), "Source Sans 3", "{name}, field {k}"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index caaa526eb..2084ab128 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -16,10 +16,10 @@ use crate::widgets::{ }; use crate::{DesignApp, icons}; -/// A link to another panel. `window.panel` shows it in the docked layout or as a flyout; it -/// toggles the flyout, so a flyout that is already open is left as it is. +/// A link to another panel: `window.panel` shows it in the docking layout. An open flyout of +/// that panel stays: `window.panel` would close it. fn show_panel(app: &mut DesignApp, panel: &str) { - if app.ui.open_panel.as_deref() != Some(panel) { + if app.ui.docking_flyout.as_deref() != Some(panel) { let _ = app.run("window.panel", json!({"panel": panel})); } } @@ -1212,7 +1212,7 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) { ) .clicked() { - app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into()); + show_panel(app, if tab == 0 { "paragraphStyles" } else { "characterStyles" }); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.spacing_mut().item_spacing.x = 12.0; @@ -1230,7 +1230,7 @@ fn text_style_section(app: &mut DesignApp, ui: &mut Ui) { let _ = app.run("style.paragraph.apply", json!({"name": cur, "clearOverrides": true})); } if icon_toggle(ui, "style-load", false, crate::i18n::tr(&app.ui.language, "Styles panel")).clicked() { - app.ui.open_panel = Some(if tab == 0 { "paragraphStyles" } else { "characterStyles" }.into()); + show_panel(app, if tab == 0 { "paragraphStyles" } else { "characterStyles" }); } }); }); @@ -1927,12 +1927,8 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } } -/// Opens the Typography panel, or focuses it when it is already showing. `window.panel` toggles a -/// flyout, so it is skipped while the Typography flyout is open. +/// Opens the Typography panel, or brings it to the front when it is already open. pub fn show_typography_panel(app: &mut DesignApp) { - if app.ui.open_panel.as_deref() == Some("typography") { - return; - } let _ = app.run("window.panel", json!({"panel": "typography"})); } @@ -3201,6 +3197,16 @@ mod tests { use super::*; + /// A link to a panel whose flyout is open leaves the flyout open. + #[test] + fn a_panel_link_keeps_its_open_flyout() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + show_panel(&mut app, "stroke"); + assert_eq!(app.ui.docking_flyout.as_deref(), Some("stroke")); + show_panel(&mut app, "stroke"); + assert!(crate::panel_docking::showing(&app, "stroke")); + } + fn language(app: &mut DesignApp) -> Value { text_attrs(app).unwrap()["chars"]["language"].clone() } diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index cb5a0b54c..756532fd5 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -3,41 +3,117 @@ use craft_ui::docking::{Layout, Location}; use serde_json::Value; -use crate::UiState; +use crate::{HiddenPanels, SavedWorkspace, UiState, panel_docking}; + +/// Saved preferences, and whether reading them discarded anything. +pub struct ReadPreferences { + pub ui: UiState, + /// False when unreadable docking data, or an old layout that could not be converted, was + /// dropped. The caller keeps the file it read instead of overwriting it. + pub intact: bool, +} impl UiState { /// Read saved preferences, discarding only unreadable optional docking metadata. /// A layout from a newer craft-ui version must not reset unrelated settings or named workspaces. pub fn from_preferences_json(bytes: &[u8]) -> serde_json::Result { + Self::read_preferences(bytes).map(|read| read.ui) + } + + /// [`Self::from_preferences_json`], also reporting whether anything was discarded. Layouts + /// saved before the canvas joined the panel layout are converted (see + /// [`panel_docking::from_dock_only`]); that is not a loss, unless one cannot be converted. + pub fn read_preferences(bytes: &[u8]) -> serde_json::Result { let mut saved: Value = serde_json::from_slice(bytes)?; - recover_docking(&mut saved); + let mut intact = recover_docking(&mut saved); if let Some(workspaces) = saved.get_mut("customWorkspaces").and_then(Value::as_array_mut) { for workspace in workspaces { - recover_docking(workspace); + intact &= recover_docking(workspace); } } - serde_json::from_value(saved) + let mut ui: UiState = serde_json::from_value(saved)?; + clear_stale_open_panels(&mut ui); + intact &= migrate(&mut ui.docking, &mut ui.floating, &ui.docking_hidden, ui.dock_expanded, ui.icon_groups.as_deref()); + for workspace in &mut ui.custom_workspaces { + let SavedWorkspace { docking, floating, docking_hidden, dock_expanded, icon_groups, .. } = workspace; + intact &= migrate(docking, floating, docking_hidden, *dock_expanded, icon_groups.as_deref()); + } + Ok(ReadPreferences { ui, intact }) } } -fn recover_docking(saved: &mut Value) { - let Some(fields) = saved.as_object_mut() else { return }; - if fields.get("docking").is_some_and(|value| serde_json::from_value::>>(value.clone()).is_err()) { - fields.remove("docking"); - } - if let Some(hidden) = fields.get_mut("dockingHidden") { - if let Some(entries) = hidden.as_object_mut() { - entries.retain(|_, value| serde_json::from_value::>(value.clone()).is_ok()); - } else { - fields.remove("dockingHidden"); +/// Files from before the canvas joined the panel layout saved the flyout open at quit as +/// `openPanel`, which is the panel a workspace opens below Properties · Pages · Layers. Only a +/// workspace's own panel stays: a built-in workspace's (Printing and Proofing: Swatches), and in a +/// saved workspace one a built-in workspace opens (saving copies it). The current workspace keeps +/// a saved workspace's panel. +fn clear_stale_open_panels(ui: &mut UiState) { + let built_in = |panel: &str| crate::menus::WORKSPACE_OPEN_PANELS.iter().any(|(_, p)| *p == panel); + for workspace in &mut ui.custom_workspaces { + if workspace.open_panel.as_deref().is_some_and(|panel| !built_in(panel)) { + workspace.open_panel = None; } } + let own = match ui.custom_workspaces.iter().find(|w| w.name == ui.workspace) { + Some(saved) => saved.open_panel.as_deref(), + None => crate::menus::workspace_open_panel(&ui.workspace), + }; + if ui.open_panel.as_deref() != own { + ui.open_panel = None; + } +} + +/// Drop docking data this version cannot read. True when nothing was dropped. +fn recover_docking(saved: &mut Value) -> bool { + let Some(fields) = saved.as_object_mut() else { return true }; + let mut intact = true; + if fields.get("docking").is_some_and(|value| serde_json::from_value::>>(value.clone()).is_err()) { + fields.remove("docking"); + intact = false; + } + if let Some(hidden) = fields.get_mut("dockingHidden") { + // `[[panel, location], …]`, or an object keyed by panel from older files. + if let Some(entries) = hidden.as_array_mut() { + let before = entries.len(); + entries.retain(|entry| serde_json::from_value::<(String, Location)>(entry.clone()).is_ok()); + intact &= entries.len() == before; + } else if let Some(entries) = hidden.as_object_mut() { + let before = entries.len(); + entries.retain(|_, value| serde_json::from_value::>(value.clone()).is_ok()); + intact &= entries.len() == before; + } else { + fields.remove("dockingHidden"); + intact = false; + } + } + intact +} + +/// Convert a layout saved before the canvas joined the panel layout. One that cannot be +/// converted gives way to the workspace's default layout, which the workspace fields +/// (`dockTab`, `openPanel`, `floating`, `dockExpanded`, `iconGroups`) still describe. False when +/// a layout was dropped that way. +fn migrate( + docking: &mut Option>, + floating: &mut Vec<(String, [f32; 2])>, + hidden: &HiddenPanels, + dock_expanded: bool, + icon_groups: Option<&[Vec]>, +) -> bool { + let Some(old) = docking.as_ref().filter(|layout| panel_docking::dock_only(layout)) else { return true }; + *docking = panel_docking::from_dock_only(old, hidden.keys(), dock_expanded, icon_groups); + if docking.is_some() { + // The converted layout holds the floating dock panels. + floating.retain(|(id, _)| crate::dock::float_only(id)); + } + docking.is_some() } #[cfg(test)] mod tests { use super::*; - use crate::{DesignApp, SavedWorkspace}; + use crate::DesignApp; + use craft_ui::docking::{Column, Node, Side}; use serde_json::json; fn populated() -> UiState { @@ -100,14 +176,241 @@ mod tests { fn unreadable_hidden_origin_does_not_discard_other_origins() { let original = populated(); let mut saved = serde_json::to_value(&original).unwrap(); - saved["dockingHidden"]["futurePanel"] = json!({"FutureLocation":{}}); - saved["customWorkspaces"][0]["dockingHidden"]["futurePanel"] = json!(false); + saved["dockingHidden"].as_array_mut().unwrap().push(json!(["futurePanel", {"FutureLocation":{}}])); + saved["customWorkspaces"][0]["dockingHidden"].as_array_mut().unwrap().push(json!(false)); let loaded = UiState::from_preferences_json(&serde_json::to_vec(&saved).unwrap()).unwrap(); assert!(!original.docking_hidden.is_empty()); assert_eq!(loaded.docking_hidden, original.docking_hidden); assert_eq!(loaded.custom_workspaces[0].docking_hidden, original.custom_workspaces[0].docking_hidden); } + #[test] + fn unreadable_docking_is_reported_so_the_file_is_kept() { + let mut saved = serde_json::to_value(populated()).unwrap(); + assert!(UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap().intact); + saved["customWorkspaces"][1]["docking"] = json!({"root":{"FutureLayout":{}}}); + assert!(!UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap().intact); + } + + /// The dock of a layout in the current shape: the canvas beside a dock at the end edge. + fn dock(layout: &Layout) -> &[Column] { + assert!(crate::panel_docking::valid(layout), "{layout:?}"); + let Some(Node::Split { first, second, .. }) = &layout.root else { panic!("expected the canvas split: {layout:?}") }; + assert!(matches!(&**first, Node::Tabs { panels, .. } if panels == &["canvas"])); + let Node::Dock { side: Side::End, columns } = &**second else { panic!("expected an end dock: {second:?}") }; + columns + } + + fn panels(column: &Column) -> Vec> { + column.groups.iter().map(|g| g.panels.iter().map(String::as_str).collect()).collect() + } + + /// Hand-written pre-trial `ui.json` files: each loads through the app's path with nothing lost. + fn load(saved: serde_json::Value) -> UiState { + UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).map(|read| read.ui).unwrap() + } + + #[test] + fn classic_dock_file_loads_as_its_workspace_default_layout() { + let ui = load(json!({ + "workspace": "Printing and Proofing", "dockTab": "layers", "dockExpanded": true, "openPanel": "swatches", + "floating": [["glyphs", [100.0, 120.0]]], + })); + assert!(ui.docking.is_none()); + let layout = crate::panel_docking::default_layout(&ui); + let columns = dock(&layout); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers"], vec!["swatches"]]); + assert_eq!(columns[0].groups[0].active, 2, "Layers stays in front"); + assert!(!columns[0].iconic && columns[1].iconic); + assert!(columns[1].groups.iter().all(|g| !g.panels.iter().any(|p| p == "glyphs" || p == "swatches"))); + assert_eq!(layout.floating.len(), 1); + assert_eq!(crate::panel_docking::window_groups(&layout.floating[0])[0].panels, ["glyphs"]); + assert_eq!(&layout.floating[0].rect[..2], &[100.0, 120.0]); + } + + /// An `openPanel` that was the flyout open at quit is not docked below Properties. + #[test] + fn a_flyout_open_at_quit_is_not_docked() { + let ui = load(json!({ + "workspace": "Essentials", "dockExpanded": true, "openPanel": "stroke", + "customWorkspaces": [ + {"name": "Mine", "dockExpanded": true, "openPanel": "effects"}, + {"name": "Proof", "dockExpanded": true, "openPanel": "swatches"}, + ], + })); + assert_eq!(ui.open_panel, None); + let layout = crate::panel_docking::default_layout(&ui); + assert_eq!(panels(&dock(&layout)[0]), [vec!["properties", "pages", "layers"]], "Stroke stays in its icon group"); + assert_eq!(ui.custom_workspaces[0].open_panel, None); + assert_eq!(ui.custom_workspaces[1].open_panel.as_deref(), Some("swatches"), "saved from Printing and Proofing"); + let ui = load(json!({"workspace": "Proof", "openPanel": "swatches", "customWorkspaces": [{"name": "Proof", "openPanel": "swatches"}]})); + assert_eq!(ui.open_panel.as_deref(), Some("swatches"), "the saved workspace's own panel"); + } + + #[test] + fn dock_only_layout_becomes_the_main_column() { + let ui = load(json!({ + "dockExpanded": true, + "docking": {"root": {"Tabs": {"panels": ["properties", "pages", "layers", "swatches"], "active": 3}}, "floating": []}, + "customWorkspaces": [{ + "name": "Split", "dockExpanded": true, + "docking": {"root": {"Split": {"axis": "Horizontal", "size": {"Ratio": 0.5}, + "first": {"Tabs": {"panels": ["links"], "active": 0}}, + "second": {"Split": {"axis": "Vertical", "size": {"Ratio": 0.5}, + "first": {"Tabs": {"panels": ["properties", "pages"], "active": 1}}, + "second": {"Stack": {"entries": [ + {"panel": "layers", "open": true, "height": null}, + {"panel": "swatches", "open": false, "height": 120.0}]}}}}}}, + "floating": []}, + }], + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers", "swatches"]]); + assert_eq!(columns[0].groups[0].active, 3); + assert!(!columns[0].iconic); + let icons = &columns[1]; + assert!(icons.iconic && icons.groups.iter().any(|g| g.panels == ["stroke", "color", "gradient"])); + assert!(icons.groups.iter().all(|g| !g.panels.iter().any(|p| p == "swatches")), "each panel appears once"); + + // Splits: a vertical split stacks groups in one column, a horizontal one adds a column + // (the dock lists columns from the window edge inwards). + let columns = dock(ui.custom_workspaces[0].docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages"], vec!["layers"], vec!["swatches"]]); + assert_eq!(columns[0].groups[0].active, 1); + assert!(columns[0].groups[2].minimized && columns[0].groups[2].height == Some(120.0), "a closed accordion entry stays minimized"); + assert_eq!(panels(&columns[1]), [vec!["links"]]); + assert!(columns[2].iconic); + } + + #[test] + fn icon_groups_become_the_iconic_column_in_order() { + let ui = load(json!({ + "dockExpanded": true, + "iconGroups": [["pages", "layers"], ["properties", "swatches", "color"]], + "docking": {"root": {"Tabs": {"panels": ["properties"], "active": 0}}, "floating": []}, + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(columns.len(), 2); + assert_eq!(panels(&columns[0]), [vec!["properties"]]); + assert!(columns[1].iconic); + assert_eq!(panels(&columns[1]), [vec!["pages", "layers"], vec!["swatches", "color"]]); + } + + #[test] + fn collapsed_dock_makes_the_main_column_iconic() { + let ui = load(json!({ + "dockExpanded": false, + "docking": {"root": {"Tabs": {"panels": ["properties", "pages", "layers"], "active": 0}}, "floating": []}, + })); + let columns = dock(ui.docking.as_ref().unwrap()); + assert_eq!(panels(&columns[0]), [vec!["properties", "pages", "layers"]]); + assert!(columns.iter().all(|c| c.iconic)); + } + + #[test] + fn floating_windows_and_hidden_places_survive_the_migration() { + let saved = json!({ + "dockExpanded": true, + "floating": [["glyphs", [40.0, 60.0]], ["character", [40.0, 60.0]], ["tabs", [10.0, 20.0]]], + "docking": { + "root": {"Tabs": {"panels": ["properties", "pages", "layers"], "active": 0}}, + "floating": [{"panels": ["glyphs", "character"], "active": 1, "rect": [40.0, 60.0, 300.0, 400.0]}], + }, + "dockingHidden": {"color": {"floating": null, "anchor": "layers", "placement": {"Split": "Bottom"}, + "size": {"Ratio": 0.5}, "stack": null}}, + }); + let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(read.intact, "converting is not a loss"); + let ui = read.ui; + let layout = ui.docking.clone().unwrap(); + let columns = dock(&layout); + assert_eq!(layout.floating.len(), 1); + assert_eq!(crate::panel_docking::window_groups(&layout.floating[0])[0].panels, ["glyphs", "character"]); + assert_eq!(crate::panel_docking::window_groups(&layout.floating[0])[0].active, 1); + assert_eq!(layout.floating[0].rect, [40.0, 60.0, 300.0, 400.0]); + assert_eq!(ui.floating, [("tabs".to_string(), [10.0, 20.0])], "the layout holds the floating dock panels"); + assert!(ui.docking_hidden.contains_key("color") && !layout.contains(&"color".to_string()), "a closed panel stays closed"); + assert!(columns[1].groups.iter().all(|g| g.panels != ["glyphs"])); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui = ui; + app.run("window.panel", json!({"panel": "color"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"color".to_string())); + assert!(!app.ui.docking_hidden.contains_key("color")); + // Saved again, the file is in the current shape and loads unchanged. + let again = UiState::from_preferences_json(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(again.docking, app.ui.docking); + } + + /// Floating windows saved as a stack of groups (`groups`, before floating windows held split + /// trees) load as a vertical split of the groups in order, sized by their heights; a window + /// whose groups were all minimized loads minimized. + #[test] + fn floating_stacks_of_groups_load_as_split_windows() { + let mut saved = serde_json::to_value(UiState::default()).unwrap(); + let mut docking = serde_json::to_value(crate::panel_docking::default_layout(&UiState::default())).unwrap(); + docking["floating"] = json!([ + {"groups": [ + {"panels": ["swatches", "color"], "active": 1, "minimized": false, "height": 300.0}, + {"panels": ["stroke"], "active": 0, "minimized": false, "height": 100.0}, + ], "rect": [400.0, 100.0, 280.0, 440.0]}, + {"groups": [{"panels": ["glyphs"], "active": 0, "minimized": true, "height": null}], "rect": [40.0, 60.0, 300.0, 400.0]}, + ]); + docking["root"]["Split"]["second"]["Dock"]["columns"][1]["groups"] = json!([{"panels": ["effects"], "active": 0}]); + saved["docking"] = docking; + let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(read.intact); + let layout = read.ui.docking.clone().unwrap(); + dock(&layout); + let Node::Split { axis: craft_ui::layout::SplitAxis::Vertical, size: craft_ui::layout::SplitSize::Ratio(ratio), first, second } = + &layout.floating[0].root + else { + panic!("expected a vertical split: {:?}", layout.floating[0]); + }; + assert!((ratio - 0.75).abs() < 1e-4, "{ratio}"); + assert_eq!(**first, Node::Tabs { panels: vec!["swatches".into(), "color".into()], active: 1 }); + assert_eq!(**second, Node::Tabs { panels: vec!["stroke".into()], active: 0 }); + assert_eq!(layout.floating[0].rect, [400.0, 100.0, 280.0, 440.0]); + assert!(!layout.floating[0].minimized && layout.floating[1].minimized); + let again = UiState::from_preferences_json(&serde_json::to_vec(&read.ui).unwrap()).unwrap(); + assert_eq!(again.docking, read.ui.docking); + } + + /// An old layout that cannot be converted gives way to the workspace's default and is + /// reported, so the file it came from is kept. + #[test] + fn an_old_layout_that_cannot_be_converted_is_reported() { + let saved = json!({"docking": {"root": {"Tabs": {"panels": ["properties", "properties"], "active": 0}}, "floating": []}}); + let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(!read.intact); + assert_eq!(read.ui.docking, None); + } + + /// `dockingHidden` as an object keyed by panel (files before craft-ui's `Workspace::hidden` + /// list) loads as the list, drops only its unreadable entries and saves as the list. + #[test] + fn hidden_panels_keyed_by_panel_load_as_the_workspace_list() { + let place = json!({"floating": null, "anchor": "layers", "placement": {"Split": "Bottom"}, "size": {"Ratio": 0.5}, "stack": null}); + let saved = json!({ + "dockingHidden": {"color": place, "swatches": place, "futurePanel": {"FutureLocation": {}}}, + "customWorkspaces": [{"name": "Old", "dockingHidden": {"stroke": place}}], + }); + let read = UiState::read_preferences(&serde_json::to_vec(&saved).unwrap()).unwrap(); + assert!(!read.intact, "the unreadable entry is reported so the file is kept"); + let ui = read.ui; + assert_eq!(ui.docking_hidden.keys().collect::>(), ["color", "swatches"]); + assert_eq!(ui.custom_workspaces[0].docking_hidden.keys().collect::>(), ["stroke"]); + let written = serde_json::to_value(&ui).unwrap(); + assert_eq!(written["dockingHidden"][0][0], "color"); + let workspace: craft_ui::docking::Workspace = + serde_json::from_value(json!({"layout": {"root": null, "floating": []}, "hidden": written["dockingHidden"]})).unwrap(); + assert_eq!(workspace.hidden, ui.docking_hidden.0, "the same shape as craft-ui's Workspace::hidden"); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui = ui; + app.run("window.panel", json!({"panel": "color"})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"color".to_string())); + assert!(!app.ui.docking_hidden.contains_key("color")); + } + #[test] fn unrelated_malformed_preferences_are_still_rejected() { assert!(UiState::from_preferences_json(br#"{"uiScale":"invalid","docking":false}"#).is_err()); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1dc0eed49..2986adc1c 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -35,7 +35,8 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `engine.execute` / `ui.menu.invoke` | `{command, params}` | Run any engine or UI command (see `engine.commands`). `ui.menu.invoke` without `params` acts like choosing the menu item: a command labelled "…" that takes parameters opens its dialog. A key the command's params description doesn't name is an error (`unknown parameter …`), not ignored | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled | | `document.inspect` | — | Pages, spreads, items, stories (overset), styles, swatches, selection | -| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, perf | +| `ui.inspect` | — | Tool, UI state, view (zoom/origin), canvas rect, panel layout (`docking`), perf | +| `ui.docking` | — | The panel layout in use, the focused panel and the docking hit rects (see Shared panel docking) | | `ui.menu.list` / `ui.tool.list` | — | Menu tree / Tools panel groups | | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | @@ -74,36 +75,127 @@ to finish before retrying a request that would exceed the pending-event limit. ## 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. +and movable, resizable floating windows. A floating window holds tab groups split side by side +or one above another, like the dock area. Drag a panel tab, or use its context menu. On the +desktop each floating window is a window of its own; on the web floating windows stay 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. +The window is one panel layout: the protected `canvas` panel (the document area) beside a +dock at the logical end edge, the right in left-to-right languages and the left in +right-to-left ones. Each built-in workspace has a default layout. Essentials and most others +have an outer column with the Properties · Pages · Layers group and an iconic column with the +other panels; Essentials Classic and Typography have their named iconic groups only. A panel a +workspace opens (Printing and Proofing: Swatches) is a group below Properties · Pages · Layers. +The `canvas` panel never moves, floats, closes or takes tabs; a panel or tab group dropped +over it floats there. 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. Preferences saved before the canvas joined the layout load with nothing lost: +the classic dock becomes the workspace's default layout, and a customized dock-only layout +becomes the dock's expanded columns (iconic if the dock was collapsed), with the remaining +panels (or the workspace's named groups) in the iconic column and its floating windows and +hidden-panel positions kept. A `ui.json` the app cannot read in full is copied to +`ui.unreadable.json` before it is saved again. -- `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. +The `window.panel.*`, `window.workspace.*` and `ui.dock` commands are craft-ui's shared +docking commands (`DockCommand`), with the same ids and params in every craft app. `panel`, +`anchor`, `target` and `before` take a panel ID or its English name in any case; `onto` is +read as `anchor`. + +- `window.panel.move {panel, anchor, zone?, before?, group?, placement?}` moves a panel into + the target group. `zone` is `tab` (default), `center`, `left`, `right`, `top`, `bottom`, + `above` or `below`; `before` reorders tabs by panel ID (`null` appends). Beside a panel in a + dock column, `top` and `bottom` add a group above or below its group and `left` and `right` + add a column on that side; in a floating window, `top` and `bottom` add a group and `left` + and `right` are errors (craft-ui's `Placement::for_zone`). `group: true` moves the panel's + whole tab group; `placement` is a serialized craft-ui `Placement` and takes precedence over + `zone` and `before`. +- `window.panel.float {panel, x?, y?, width?, height?, group?, column?}` (and + `window.floatPanel`) detaches a panel, its tab group (`group`) or its dock column + (`column`). Without a position it floats 24 points inside the dock area's top-left corner, + clear of the menu bar; a panel floats at its own size and a tab group or column at the size + a drag would float it at. Given values replace these. A panel already alone in a floating + window moves or resizes, and a tab group that is already a whole floating window keeps its + rect unless the params change it. +- `window.panel.dock {panel, anchor?, zone?, before?, group?, column?, side?}` (and + `window.dockPanel`) docks beside `anchor`, or in a new column of the dock at `side` + (`Start` or `End`; a new dock when there is none). A panel that already leads that dock's + last column stays where it is. `zone` places the panel beside `anchor`, so a `zone` without + `anchor` or `side` is an error. Without `anchor` and `side`, a panel that floated from + the dock goes back to that place, and another floating panel joins the first dock group. For + the float-only Tabs panel (`tabs`), which has its own window outside the layout, float opens + that window and dock closes it. +- `window.panel.activate {panel}` (and `window.panel {panel}`) reveals and selects a panel, + restoring its saved position when possible; a panel in an iconic column opens as a flyout + beside its icon. `window.panel.close {panel}` hides it. +- `window.panel.closeGroup {panel}` hides the panel and the panels tabbed with it, and + `window.panel.closeOthers {panel}` hides the panels tabbed with it; the result lists them in + `closed`. +- `window.panel.minimize {panel, minimized?}` minimizes the panel's group to its tab strip and + `window.panel.iconic {panel, iconic?}` collapses its dock column to icons; without the flag + they toggle. +- `window.workspace.new {name}`, `window.workspace.delete {name}` and `window.workspace.reset` + (also `ui.dock.reset`) are `window.newWorkspace`, `window.deleteWorkspace` and + `window.resetWorkspace`. `window.workspace {name}` switches workspaces as before. +- `ui.dock {action}` or `ui.dock {operation, …}` is the shape LightCraft, PDFCraft, CADCraft + and SoundCraft use: `open`, `close`, `activate`, `float {rect}`, `moveFloating {rect}`, + `move {target, zone?, before?}`, `setStackOpen {open}`, `resizeStack {height}` and + `resizeSplit {path, size}`, with `rect` as `[x, y, width, height]`. +- `window.panel.window` (a panel into or out of its own window) and `window.workspace.lock` are + not available in DesignCraft and return an error. +- `window.collapseToIcons` collapses every dock column to icons. 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). +updates a floating window's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}` +resizes the root divider (`false`/`true` path entries select first/second children), and +`{"action":{"ResizeWindowSplit":{"panel":"swatches","path":[],"size":{"Ratio":0.6}}}}` resizes +a divider in the floating window that holds `panel`, by its path from the window's root. +Floating windows saved as a stack of groups (`{"groups": […], "rect": […]}`) load as groups +one above another, sized by their saved heights. `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. -After panel customization, `app.tablePanel` (including Shift+F9) still toggles the -Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel; -`dockExpanded:false` hides the root dock while floating groups remain visible. -The icon rail can reopen a collapsed dock through any root panel. Activating a -floating panel also raises its group. These operations change only UI layout. +`app.tablePanel` (including Shift+F9) toggles the Table panel. `ui.set` with `closePanel` +closes the last explicitly opened panel; `dockExpanded:false` collapses every dock column +to icons and `dockExpanded:true` expands the outer column. Activating a floating panel also +raises its group. These operations change only UI layout. + +Clicking an icon in an iconic column opens its group as a flyout beside the column, on the +canvas side; clicking the icon again closes it. With Auto-Collapse Iconic Panels on (the +default; a column bar's menu toggles it), Escape or a click outside the flyout closes it too. + +`ui.docking` returns the panel layout in use; `ui.inspect` includes the same object as +`docking`: + +```json +{"layout": {"root": {"Split": {"axis": "Horizontal", "size": {"FixedSecond": 297.0}, + "first": {"Tabs": {"panels": ["canvas"], "active": 0}}, + "second": {"Dock": {"side": "End", "columns": [ + {"groups": [{"panels": ["properties", "pages", "layers"], "active": 0}], "width": 260.0, + "iconic": false, "iconic_width": 37.0}, + {"groups": [{"panels": ["stroke"], "active": 0}, …], "width": 260.0, "iconic": true, + "iconic_width": 37.0}]}}}}, + "floating": []}, + "customized": false, "focused": "canvas", "flyout": null, "autoCollapse": true, + "rightToLeft": false, "hidden": [], + "hitRects": [{"hit": {"Tab": "properties"}, "rect": [983.0, 0.0, 74.0, 27.0]}, + {"hit": {"DragBar": "properties"}, "rect": [1190.0, 0.0, 56.0, 27.0]}, + {"hit": {"Icon": "stroke"}, "rect": [1243.0, 14.0, 37.0, 30.0]}, …]} +``` + +`layout` is craft-ui's serialized `Layout`; `customized` is false while the workspace's +default layout is in use; `focused` is the panel (or `canvas`) last clicked or selected, or +null; `flyout` is the panel whose iconic-column flyout is open (as drawn in the last frame, +or as a command that opens one sets it); `autoCollapse` is the +Auto-Collapse Iconic Panels setting; `hidden` lists closed panels that remember their place. +`hitRects` lists where each docking gesture starts in the last frame, from craft-ui's +`DockArea::hit_rects`: `hit` is a serialized craft-ui `Hit` (`Tab`, `DragBar`, `Border`, +`Overflow`, `ColumnBar`, `Collapse`, `StackToggle`, `Title`, `Close`, `Icon`, `IconGrip`, +`Resize` with its edge, or `Divider`), naming groups, columns and windows by their first panel; +`rect` is `[x, y, width, height]` in points. Drag from a rect's centre with `ui.drag`, or click +it with `ui.click`, to script a gesture. diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index 26d73aa77..eb32303a9 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -1,8 +1,8 @@ # Shared PDF backend -DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the five Hayro reader crates (with -`hayro-cmap`) to craft-libs revision `9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef`. Existing `craft-ui` and `craft-print` revisions -remain unchanged. The reader migration is reviewed on top of the separate writer change. +DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the six Hayro reader crates (with +`hayro-cmap` and `hayro-postscript`) to craft-libs revision `a0916808f7d78bed8c7b90cdf937176015454a7f`. `craft-ui`, +`craft-print` and the other craft-libs crates are pinned to the same revision. The reader migration is reviewed on top of the separate writer change. ## Writing @@ -62,8 +62,8 @@ report known backend warnings; they do not certify all PDF fidelity. DesignCraft uses a 512 MiB reader input bound, checked before parsing or copying. Raster output has shared geometry/allocation bounds. Native unwind guards turn escaped backend panics into errors; WASM aborts and process-wide allocation failures are not recoverable. -Application fonts, image assets, editing and export presets remain app-owned. Existing -craft-ui/craft-print versions are preserved. The reader migration builds on the independently qualified writer change. +Application fonts, image assets, editing and export presets remain app-owned. craft-ui and +craft-print are pinned separately. The reader migration builds on the independently qualified writer change. The shared commit must be published before a fresh public checkout can resolve these pins. No sibling checkout is required. Licences and existing backend asset notices are recorded in diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4044fb78f..428ed9bf7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -442,7 +442,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Pathfinder panel as InDesign's: Paths (Join Path `path.join`, Open/Close Path, Reverse Path), Pathfinder, Convert Shape, Convert Point (`path.convertPoints`: plain, corner, smooth, symmetrical). - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). -- Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`. +- Floating panels: drag a panel's tab, or a group by its drag bar, off the dock (or choose Float Panel) into a movable floating window that resizes from every edge; Dock Panel (in its tab menu and ≡ menu) puts it back where it was; positions persist; `window.panel.float` / `window.panel.dock` (aliases `window.floatPanel` / `window.dockPanel`). - Place SVG (vector in PDF export via krilla-svg, resvg on screen, text in the bundled fonts), Photoshop (.psd/.psb composite in its colour mode — Bitmap, Grayscale, Duotone, Indexed, RGB, CMYK, Lab, 8/16-bit — with merged transparency; extra alpha and spot channels don't change the image), BMP, and Illustrator files saved with PDF compatibility (.ai). A swatch on a placed greyscale or bitmap image (IDML ``) colours it on screen and in PDF. New `designcraft-images` crate (L2) for sniffing, sizes and decoding. EPS is still missing. - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs index 7e9677d52..e7caa5d53 100644 --- a/xtask/src/pdf_deps.rs +++ b/xtask/src/pdf_deps.rs @@ -4,7 +4,7 @@ use std::process::Command; use serde_json::Value; -const REV: &str = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"; +const REV: &str = "a0916808f7d78bed8c7b90cdf937176015454a7f"; const PACKAGES: &[(&str, &str)] = &[ ("craft-pdf", "0.1.0"), ("krilla", "0.8.2"), @@ -13,6 +13,7 @@ const PACKAGES: &[(&str, &str)] = &[ ("hayro-syntax", "0.7.2"), ("hayro-jbig2", "0.3.0"), ("hayro-cmap", "0.1.0"), + ("hayro-postscript", "0.1.0"), ]; pub fn run(root: &Path) -> Result<(), String> {