diff --git a/Cargo.lock b/Cargo.lock index 9b09db60..c8257cdf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arboard" version = "3.6.1" @@ -713,6 +719,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.8" @@ -790,6 +806,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -861,6 +886,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "digest" version = "0.10.7" @@ -1066,6 +1104,27 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + [[package]] name = "either" version = "1.18.0" @@ -1438,6 +1497,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1940,6 +2008,25 @@ dependencies = [ "generic-array", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.15.0" @@ -2067,6 +2154,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + [[package]] name = "krilla" version = "0.8.2" @@ -2119,6 +2216,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2740,6 +2843,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "orbclient" version = "0.3.55" @@ -4744,8 +4857,10 @@ dependencies = [ name = "vectorcraft-ui-egui" version = "0.9.0" dependencies = [ + "craft-ui", "egui", "egui_extras", + "egui_kittest", "image", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index dfbb344c..748ecb6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ rust-version = "1.95" repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/ROADMAP.md b/ROADMAP.md index 6b72f0c4..f3a267ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,9 @@ # VectorCraft Roadmap +- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available. + +- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned. + **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%) · **Target:** Adobe Illustrator 2026 (30.x) diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 277788c1..e277f0e3 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -189,7 +189,42 @@ fn read_prefs() -> Option { return None; } let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()))?; - serde_json::from_slice(&bytes).ok() + decode_saved_prefs(&bytes) +} + +/// Recover only optional persisted docking metadata. Runtime UiState and command decoding +/// remains strict, and an unreadable workspace layout cannot discard unrelated preferences. +fn decode_saved_prefs(bytes: &[u8]) -> Option { + use serde_json::{Map, Value, json}; + + fn recover(object: &mut Map, hidden_key: &str) { + if let Some(layout) = object.get("docking") + && serde_json::from_value::(json!({"docking": layout})).is_err() + { + object.remove("docking"); + } + if let Some(hidden) = object.get_mut(hidden_key) { + if let Value::Object(entries) = hidden { + entries.retain(|panel, location| { + serde_json::from_value::(json!({"docking_hidden": {panel: location}})).is_ok() + }); + } else { + object.remove(hidden_key); + } + } + } + + let mut value: Value = serde_json::from_slice(bytes).ok()?; + let object = value.as_object_mut()?; + recover(object, "docking_hidden"); + if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") { + for workspace in workspaces { + if let Some(object) = workspace.as_object_mut() { + recover(object, "dockingHidden"); + } + } + } + serde_json::from_value(value).ok() } fn load_prefs(app: &mut VectorcraftApp, saved: Option) { @@ -649,6 +684,51 @@ mod tests { vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() } } + #[test] + fn unreadable_saved_docking_keeps_preferences_and_every_workspace() { + use serde_json::json; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.ui.toolbar = false; + app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"}); + app.ui.custom_workspaces = + vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")]; + let original = serde_json::to_value(&app.ui).unwrap(); + let valid_location = original["docking_hidden"]["layers"].clone(); + assert!(valid_location.is_object()); + for invalid_layout in [json!({"root":{"FutureNode":{}},"floating":[]}), json!(false)] { + for invalid_hidden in [json!({"layers":{"placement":{"FuturePlacement":{}}},"swatches":valid_location}), json!(["malformed"])] { + let mut raw = original.clone(); + raw["docking"] = invalid_layout.clone(); + raw["docking_hidden"] = invalid_hidden.clone(); + raw["custom_workspaces"][0]["docking"] = invalid_layout.clone(); + raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone(); + assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict"); + let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap(); + assert_eq!(loaded.engine_prefs, app.ui.engine_prefs); + assert!(!loaded.toolbar); + assert!(loaded.docking.is_none()); + assert!(!loaded.docking_hidden.contains_key("layers")); + if invalid_hidden.is_object() { + assert_eq!(serde_json::to_value(loaded.docking_hidden.get("swatches")).unwrap(), valid_location); + } else { + assert!(loaded.docking_hidden.is_empty()); + } + assert_eq!(loaded.custom_workspaces.len(), 2); + assert_eq!(loaded.custom_workspaces[0].name, "Future"); + assert!(loaded.custom_workspaces[0].docking.is_none()); + assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers")); + assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]); + } + } + let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(roundtrip).unwrap(), original); + assert!( + decode_saved_prefs(&serde_json::to_vec(&json!({"toolbar":"invalid"})).unwrap()).is_none(), + "unrelated fields retain normal strict parsing" + ); + } + #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { // Preferences › User Interface › System Title Bar gives the window back its system diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 7376e2f0..4dd258af 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -4,21 +4,15 @@ use std::path::PathBuf; +#[path = "profile.rs"] +mod profile; + /// The settings file `ui.json` of the app `name`: in a folder named `name` in ~/Library/Application /// Support (macOS) or %APPDATA% (Windows), or `lower` in $XDG_CONFIG_HOME or ~/.config (Linux and /// BSD). pub fn prefs_path_for(name: &str, lower: &str) -> Option { - let base = if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name)) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name)) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) - .map(|c| c.join(lower)) - }; - base.map(|b| b.join("ui.json")) + let legacy = name == "DrawCraft" || lower == "drawcraft"; + profile::prefs_path(legacy) } /// Have the font scans read VectorCraft's own Fonts folder, next to the settings, which diff --git a/apps/vectorcraft/src/profile.rs b/apps/vectorcraft/src/profile.rs new file mode 100644 index 00000000..c73596a0 --- /dev/null +++ b/apps/vectorcraft/src/profile.rs @@ -0,0 +1,66 @@ +//! Native profile paths. Isolated profiles never migrate preferences from a user's old profile. + +use std::ffi::OsString; +use std::path::PathBuf; + +pub fn prefs_path(legacy: bool) -> Option { + prefs_path_with(std::env::consts::OS, legacy, |key| std::env::var_os(key)) +} + +fn prefs_path_with(os: &str, legacy: bool, env: impl Fn(&str) -> Option) -> Option { + if let Some(root) = env("VECTORCRAFT_CONFIG_DIR") { + // Never fall back to DrawCraft's real profile when the isolated file does not exist. + return (!legacy && !root.is_empty()).then(|| PathBuf::from(root).join("ui.json")); + } + let (name, lower) = if legacy { ("DrawCraft", "drawcraft") } else { ("VectorCraft", "vectorcraft") }; + let base = match os { + "macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support").join(name)), + "windows" => env("APPDATA").map(|p| PathBuf::from(p).join(name)), + _ => env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join(lower)), + }; + base.map(|p| p.join("ui.json")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn environment(key: &str) -> Option { + match key { + "HOME" => Some("/user".into()), + "APPDATA" => Some("/roaming".into()), + "XDG_CONFIG_HOME" => Some("/xdg-config".into()), + _ => None, + } + } + + #[test] + fn isolated_profile_has_no_legacy_fallback_on_any_platform() { + for os in ["macos", "windows", "linux"] { + let env = |key: &str| { + if key == "VECTORCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) } + }; + assert_eq!(prefs_path_with(os, false, env), Some("/isolated/ui.json".into())); + assert_eq!(prefs_path_with(os, true, env), None); + } + } + + #[test] + fn empty_override_does_not_fall_back_to_user_files() { + let env = |key: &str| if key == "VECTORCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) }; + assert_eq!(prefs_path_with("macos", false, env), None); + assert_eq!(prefs_path_with("macos", true, env), None); + } + + #[test] + fn unset_override_keeps_platform_paths_and_legacy_migration() { + assert_eq!(prefs_path_with("macos", false, environment), Some("/user/Library/Application Support/VectorCraft/ui.json".into())); + assert_eq!(prefs_path_with("macos", true, environment), Some("/user/Library/Application Support/DrawCraft/ui.json".into())); + assert_eq!(prefs_path_with("windows", false, environment), Some("/roaming/VectorCraft/ui.json".into())); + assert_eq!(prefs_path_with("linux", false, environment), Some("/xdg-config/vectorcraft/ui.json".into())); + assert_eq!( + prefs_path_with("linux", true, |key| if key == "HOME" { environment(key) } else { None }), + Some("/user/.config/drawcraft/ui.json".into()) + ); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 29c4e26a..748d32e1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -977,7 +977,7 @@ impl Renderer { fn isolation_path(&mut self, f: &Frame) -> Option<(NodeId, Vec)> { let isolated = f.opts.isolated.filter(|_| !self.isolation_settled)?; let doc = std::ptr::from_ref(f.doc) as usize; - if !self.isolation.as_ref().is_some_and(|(stamp, d, id, _)| (*stamp, *d, *id) == (self.stamp, doc, isolated)) { + if self.isolation.as_ref().is_none_or(|(stamp, d, id, _)| (*stamp, *d, *id) != (self.stamp, doc, isolated)) { self.isolation = Some((self.stamp, doc, isolated, f.doc.ancestry(isolated).unwrap_or_default())); } let path = self.isolation.as_ref().map(|(.., path)| path.clone()).filter(|p| !p.is_empty())?; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d3299868..7591e490 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap workspace = true [dependencies] +craft-ui = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } @@ -33,6 +34,7 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } vectorcraft-testkit = { workspace = true } wat = { workspace = true } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 224d5dff..7f4d13c3 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -47,13 +47,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.add_space(2.0); // With the macOS menu bar the menus are at the top of the screen instead. - let menus_end = if app.services.native_menu.is_some() { - let full = ui.max_rect(); - ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "VectorCraft", egui::FontId::proportional(13.5), t.text); - ui.cursor().min.x - } else { - menus::menu_bar(app, ui) - }; + let menus_end = if app.services.native_menu.is_some() { ui.cursor().min.x } else { menus::menu_bar(app, ui) }; // The right-side group fills the space after the menus from the right; when it runs // short, Discord goes first (it is also under Help), then the search icon (the palette // stays under its shortcut), then the workspace switcher narrows. @@ -97,6 +91,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(10.0); crate::community::discord_button(app, ui, false); } + if app.services.native_menu.is_some() { + let title = ui.painter().layout_no_wrap("VectorCraft".into(), egui::FontId::proportional(13.5), t.text); + let title_rect = egui::Rect::from_center_size(full.center(), title.size()); + // Keep the title centered only while the interactive groups leave room for it. + if title_rect.left() >= menus_end + 8.0 && title_rect.right() <= ui.min_rect().left() - 8.0 { + ui.painter().galley(title_rect.min, title, t.text); + } + } }); }); if custom { @@ -153,7 +155,9 @@ pub enum AnchorControls { /// Which anchor controls show for the selection and the active tool. pub fn anchor_controls(app: &VectorcraftApp) -> AnchorControls { - let Some(st) = app.session.active() else { return AnchorControls::None }; + let Some(st) = app.session.active() else { + return AnchorControls::None; + }; if !st.selection.anchors.is_empty() { return AnchorControls::All; } @@ -229,7 +233,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); let anchors = anchor_controls(app); let label = match &first { - Some(_) if sel.len() == 1 && anchors != AnchorControls::None => tl!("Anchor Point"), + Some(_) if sel.len() == 1 && anchors != AnchorControls::None => { + tl!("Anchor Point") + } Some(vectorcraft_doc::Node { kind: NodeKind::Image(im), .. }) if sel.len() == 1 => { if im.link.is_some() { tl!("Linked File") @@ -237,7 +243,9 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { tl!("Embedded") } } - Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => tl!("Image Tracing"), + Some(n) if sel.len() == 1 && crate::panels::image_trace::is_trace(n) => { + tl!("Image Tracing") + } _ => tl!(crate::panels::appearance::object_label(app)), }; ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); @@ -571,11 +579,12 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let more = app.background.jobs.len() - 1; let label = crate::i18n::msg(&job.label); let text = if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") }; - ui.label(egui::RichText::new(text).size(11.0).color(t.text)); ui.add(egui::Spinner::new().size(12.0).color(t.accent)); + ui.add(egui::Label::new(egui::RichText::new(text).size(11.0).color(t.text)).truncate()); } else if !app.ui.status.is_empty() { // The message stays English in `ui.status` (agents and tests read it). - ui.label(egui::RichText::new(crate::i18n::msg(&app.ui.status)).size(11.0).color(t.text)); + let message = crate::i18n::msg(&app.ui.status); + ui.add(egui::Label::new(egui::RichText::new(message).size(11.0).color(t.text)).truncate()); } }); }); @@ -1074,3 +1083,161 @@ mod tests { assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft"); } } + +#[cfg(test)] +mod visual_regressions { + use super::*; + + fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { + let mut input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale); + input + } + + struct NativeMenus; + impl crate::native_menu::Backend for NativeMenus { + fn sync(&mut self, _: &crate::native_menu::MenuBar) {} + fn drain(&mut self) -> Vec { + Vec::new() + } + } + + #[test] + fn capture_chrome_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { + return; + }; + std::fs::create_dir_all(&directory).unwrap(); + for brightness in theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus))); + app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10)); + let mut ready = false; + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(width, 200.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !ready { + egui_extras::install_image_loaders(ui.ctx()); + theme::install_fonts(ui.ctx()); + theme::apply(ui.ctx(), brightness); + ready = true; + return; + } + app_bar(app, ui); + status_bar(app, ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("vectorcraft-chrome-{brightness:?}-{width}-{scale}x"); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + let mut regions = serde_json::Map::new(); + for id in ["app_bar", "status_bar"] { + let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect; + regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()])); + } + let dynamic: Vec<_> = harness + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.job.text.contains(" fps") => { + let r = text.visual_bounding_rect(); + Some(json!([r.left(), r.top(), r.width(), r.height()])) + } + _ => None, + }) + .collect(); + std::fs::write( + directory.join(format!("{stem}.json")), + serde_json::to_vec_pretty(&json!({ + "screen": [width, 200.0], "scale": scale, "regions": regions, "native_dynamic_text_regions": dynamic + })) + .unwrap(), + ) + .unwrap(); + } + } + } + } + + #[test] + fn native_title_does_not_overlap_header_controls() { + for brightness in theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + egui_extras::install_image_loaders(&ctx); + theme::install_fonts(&ctx); + theme::apply(&ctx, brightness); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.services.native_menu = Some(crate::native_menu::NativeMenu::new(Box::new(NativeMenus))); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| app_bar(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(width, 640.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let texts: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) => Some(text), + _ => None, + }) + .collect(); + let title = texts.iter().find(|text| text.galley.job.text == "VectorCraft"); + if width >= 1280.0 { + assert!(title.is_some(), "the title remains visible when the header has room"); + } + if let Some(title) = title { + for text in texts.iter().filter(|text| text.galley.job.text != "VectorCraft") { + assert!( + !title.visual_bounding_rect().intersects(text.visual_bounding_rect()), + "{width}: title overlaps {:?}", + text.galley.job.text + ); + } + } + } + } + } + } + + #[test] + fn long_status_stays_between_navigation_and_performance_readouts() { + for brightness in theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + egui_extras::install_image_loaders(&ctx); + theme::install_fonts(&ctx); + theme::apply(&ctx, brightness); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.ui.status = format!("Saved /Users/example/Documents/{}/illustration.svg", "long-folder-name/".repeat(10)); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| status_bar(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(width, 640.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let texts: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) => Some(text), + _ => None, + }) + .collect(); + let status = texts.iter().find(|text| text.galley.job.text.starts_with("Saved ")).expect("status stays visible"); + assert_eq!(status.galley.rows.len(), 1, "status is one line"); + assert!(status.galley.elided, "long status retains the full text for the label tooltip"); + let bounds = status.visual_bounding_rect(); + assert!(bounds.left() >= 0.0 && bounds.right() <= width, "{width}: {bounds:?}"); + for text in texts.iter().filter(|text| !text.galley.job.text.starts_with("Saved ")) { + assert!(!bounds.intersects(text.visual_bounding_rect()), "{width}: status overlaps {:?}", text.galley.job.text); + } + } + } + } + } +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 899d56c4..62cf2df9 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -26,7 +26,7 @@ fn column_left_id() -> egui::Id { /// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's /// (egui temp memory, one frame old). Popped-out panels open left of it. -fn dock_left_id() -> egui::Id { +pub(crate) fn dock_left_id() -> egui::Id { egui::Id::new("dock-left") } @@ -43,7 +43,7 @@ pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) { /// The strip along the top of a dock column with its double arrow: » collapses the dock to icons, /// « expands it. The whole strip is the click target (as on the toolbar). True when clicked. -fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool { +pub(crate) fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool { let t = Tokens::get(ui.ctx()); let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click()); ui.painter().rect_filled(hdr, 0.0, t.tab_strip); @@ -66,20 +66,22 @@ fn toggle(app: &mut VectorcraftApp, collapsed: bool) { /// One icon of the column: a click pops its panel out (or puts it away); dragged out of the /// column (`column`), the panel floats. -fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, column: Rect) { +fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &str, icon: &str, _column: Rect) { let open = app.ui.open_panel.as_deref() == Some(id); // Its own id: the icons below it move up when it floats, and mustn't take over its drag. let (_, rect) = ui.allocate_space(vec2(30.0, 30.0)); let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label))); if resp.clicked() { app.ui.open_panel = if open { None } else { Some(id.to_string()) }; } - if resp.dragged() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !column.contains(p)) { - floating::tear(app, ui.ctx(), &[id], id, floating::strip_grab(ui.ctx(), resp.rect.left())); - } + crate::panel_docking::legacy_tab(app, ui, id, &resp); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + if crate::panel_docking::show(app, ui) { + return; + } let t = Tokens::get(ui.ctx()); let shown = floating::shown_tab(&app.ui); // Main tabbed group, unless it is collapsed to icons or all its panels float. @@ -173,36 +175,36 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.tab_strip); ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); - let out = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p)); + let outside = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p)); // What a drag out of the strip floats: the panels, the one shown, where its tab began. let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None; - let mut x = strip.left(); - for tab in DockTab::ALL { - let (id, label, _) = tab.info(); - if floating::group_of(&app.ui, id).is_some() { - continue; - } - let galley = - ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.5), if tab == active { t.text_strong } else { t.text_dim }); - let r = Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0)); - let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click_and_drag()); - if tab == active { - ui.painter().rect_filled(r, 0.0, t.panel); - } - ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); - ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); + let tabs: Vec<_> = floating::docked_tabs(&app.ui).collect(); + let names: Vec<_> = tabs + .iter() + .map(|tab| { + let (id, label, _) = tab.info(); + (id, label) + }) + .collect(); + let selected = tabs.iter().position(|&tab| tab == active).unwrap_or(0); + let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); + let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0)); + let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true); + for (i, resp) in out.responses { + let Some(&tab) = tabs.get(i) else { continue }; + let id = tab.info().0; if resp.clicked() { app.ui.dock_tab = tab; - } else if resp.dragged() && out { - tear = Some((vec![id], id, r.left())); } - x = r.right(); + crate::panel_docking::legacy_tab(app, ui, id, &resp); } - let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); - let rest = Rect::from_min_max(egui::pos2(x, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom())); + if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() { + app.ui.dock_tab = tab; + } + let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom())); if rest.width() > 0.0 { let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag()); - if resp.dragged() && out { + if resp.dragged() && outside { tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left())); } resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); @@ -215,6 +217,92 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { floating::group_of(&app.ui, active.info().0).is_some() } +/// App styling and overflow menus around the shared tab fitting/interaction components. Docking +/// and floating groups consume the responses themselves, preserving their different drag rules. +pub(crate) struct PanelTabs { + pub responses: Vec<(usize, egui::Response)>, + pub picked: Option, + pub right: f32, +} + +pub(crate) fn panel_tabs( + ui: &mut Ui, + id: &str, + area: Rect, + tabs: &[(&str, &str)], + selected: usize, + font: egui::FontId, + separators: bool, +) -> PanelTabs { + let t = Tokens::get(ui.ctx()); + let labels: Vec<_> = tabs.iter().map(|&(_, label)| tl!(label)).collect(); + let galleys: Vec<_> = labels + .iter() + .enumerate() + .map(|(i, label)| ui.painter().layout_no_wrap((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim })) + .collect(); + let natural: Vec<_> = galleys.iter().map(|g| g.size().x + 24.0).collect(); + let fit = craft_ui::tabs::fit(&natural, selected, area.width(), 40.0, 18.0); + let mut out = PanelTabs { responses: Vec::with_capacity(fit.shown.len()), picked: None, right: area.left() }; + for (i, width) in fit.shown { + let Some((key, _)) = tabs.get(i) else { continue }; + let Some(label) = labels.get(i) else { continue }; + let Some(galley) = galleys.get(i) else { continue }; + let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(width, area.height())); + let shrunk = natural.get(i).is_some_and(|natural| *natural > width + 0.5); + let galley = if shrunk { + let mut job = + egui::text::LayoutJob::simple_singleline((*label).to_string(), font.clone(), if i == selected { t.text_strong } else { t.text_dim }); + job.wrap = + egui::text::TextWrapping { max_width: (width - 8.0).max(1.0), max_rows: 1, break_anywhere: true, overflow_character: Some('…') }; + ui.painter().layout_job(job) + } else { + galley.clone() + }; + let resp = craft_ui::tabs::Tab::new(ui.id().with((id, key)), label, i == selected) + .sense(Sense::click_and_drag()) + .focus_stroke(Stroke::new(1.0, t.accent)) + .show_at(ui, rect, |ui, _| { + if i == selected { + ui.painter().rect_filled(rect, 0.0, t.panel); + } + ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).galley(rect.center() - galley.size() / 2.0, galley, t.text); + if separators { + ui.painter().line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0, t.border)); + } + }); + out.responses.push((i, if shrunk { resp.on_hover_text(*label) } else { resp })); + out.right = rect.right(); + } + if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { + let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(fit.overflow_width, area.height())); + let resp = ui.interact(rect, ui.id().with((id, "overflow")), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(rect, 0.0, t.hover); + } + if resp.has_focus() && rect.width() > 4.0 { + ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + icons::paint(ui, "chevrons-right", Rect::from_center_size(rect.center(), vec2(12.0, 12.0)).intersect(rect), t.text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panels"))); + let resp = resp.on_hover_text(tl!("Panels")); + egui::Popup::menu(&resp).show(|ui| { + widgets::menu_scroll(ui, |ui| { + for i in fit.overflow { + if let Some(label) = labels.get(i) + && ui.button(*label).clicked() + { + out.picked = Some(i); + ui.close(); + } + } + }); + }); + out.right = rect.right(); + } + out +} + /// A panel popped out next to the icon column: an icon panel, or one of the tabbed group's panels /// while the dock is collapsed. Dragging its title floats it. pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { @@ -337,6 +425,98 @@ mod tests { use super::*; use crate::toolbar::tests::{wheel, widget_rects}; + #[test] + fn tab_headers_fit_narrow_widths_keep_the_selected_tab_and_clip_labels() { + let ctx = egui::Context::default(); + egui_extras::install_image_loaders(&ctx); + theme::install_fonts(&ctx); + theme::apply(&ctx, theme::Brightness::MediumDark); + let names = [("one", "Long panel name"), ("two", "Another long panel name"), ("three", "Layers")]; + for width in [0.0, 1.0, 5.0, 18.0, 19.0, 80.0, 180.0, 300.0] { + for selected in 0..names.len() { + let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(width, 32.0)); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let out = panel_tabs(ui, "fit-test", area, &names, selected, theme::semibold(12.5), true); + assert!(out.responses.iter().any(|(i, _)| *i == selected), "selected index {selected} stays on the strip"); + assert!(out.right <= area.right() + 0.001, "{width}: strip ends at {}", out.right); + for (_, response) in &out.responses { + if response.rect.is_positive() { + assert!(area.contains_rect(response.rect), "{width}: {:?}", response.rect); + } + } + }); + output.textures_delta.clear(); + for shape in output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::Text(_))) { + assert!(shape.clip_rect.right() <= area.right() + 0.001, "{width}: a label is clipped to its tab"); + } + if width > 0.0 && width < 18.0 { + assert!( + output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(rect) if rect.brush.is_some())), + "the actual overflow SVG was painted" + ); + } + } + } + } + + #[test] + fn overflow_menu_can_activate_a_hidden_panel() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + egui_extras::install_image_loaders(&ctx); + theme::install_fonts(&ctx); + theme::apply(&ctx, theme::Brightness::MediumDark); + let names = [("properties", "Properties"), ("layers", "Layers"), ("libraries", "Libraries")]; + let mut selected = 0; + let mut time = 0.0; + let mut frame = |events| { + time += 0.1; + let input = egui::RawInput { + events, + time: Some(time), + screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 300.0))), + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| { + let area = Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(90.0, 32.0)); + let out = panel_tabs(ui, "overflow-test", area, &names, selected, theme::semibold(12.5), true); + if let Some(picked) = out.picked { + selected = picked; + } + }); + output.textures_delta.clear(); + output + }; + let button = |output: &egui::FullOutput, label: &str| { + let bounds = output + .platform_output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .find(|(_, n)| n.label() == Some(label) && n.role() == egui::accesskit::Role::Button) + .unwrap() + .1 + .bounds() + .unwrap(); + egui::pos2((bounds.x0 + bounds.x1) as f32 * 0.5, (bounds.y0 + bounds.y1) as f32 * 0.5) + }; + let press = + |pos, pressed| egui::Event::PointerButton { pos, pressed, button: egui::PointerButton::Primary, modifiers: egui::Modifiers::NONE }; + frame(vec![]); + let output = frame(vec![]); + let overflow = button(&output, "Panels"); + frame(vec![egui::Event::PointerMoved(overflow), press(overflow, true)]); + frame(vec![press(overflow, false)]); + let output = frame(vec![]); + let libraries = button(&output, "Libraries"); + frame(vec![egui::Event::PointerMoved(libraries), press(libraries, true)]); + frame(vec![press(libraries, false)]); + assert_eq!(selected, 2); + } + #[test] fn every_flyout_stays_left_of_the_icon_column() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index d435a7a0..cd5ec9f6 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -50,7 +50,7 @@ const TRAVEL: f32 = 4.0; /// What a move carries. #[derive(Clone, Copy, Debug, PartialEq)] -enum Moving { +pub(crate) enum Moving { /// The floating group holding this panel. Panel(&'static str), /// The floating Tools panel. @@ -59,16 +59,17 @@ enum Moving { /// A move in progress: what moves, where the pointer holds it from its top-left corner, and where /// the press began. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] struct Move { what: Moving, grab: Vec2, from: Pos2, + original: UiState, } /// Where a moved group or Tools panel lands. #[derive(Clone, Copy, Debug, PartialEq)] -enum Drop { +pub(crate) enum Drop { /// Back in the dock (the Tools panel: at the window's left edge). Dock, /// Stacked with the floating group holding this panel. @@ -85,6 +86,15 @@ fn move_id() -> Id { Id::new("floating-move") } +fn suppressed_id() -> Id { + move_id().with("suppressed-until-primary-press") +} + +fn gesture_allowed(ctx: &egui::Context) -> bool { + ctx.input(|input| input.focused && !input.key_pressed(egui::Key::Escape)) + && !ctx.data(|data| data.get_temp::(suppressed_id()).unwrap_or(false)) +} + /// The dock (tabbed group and icon column) as last drawn: a group dropped there docks. pub(crate) fn dock_rect_id() -> Id { Id::new("floating-dock-rect") @@ -266,9 +276,12 @@ pub fn clamp(pos: [f32; 2], size: Vec2, screen: Rect) -> Pos2 { } /// Start moving `what` with the pointer, held `grab` from its top-left corner. -fn start(ctx: &egui::Context, what: Moving, grab: Vec2) { +fn start(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, grab: Vec2) { + if !gesture_allowed(ctx) { + return; + } let Some(from) = ctx.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())) else { return }; - ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from })); + ctx.data_mut(|d| d.insert_temp(move_id(), Move { what, grab, from, original: app.ui.clone() })); } /// Where the pointer holds a group torn off by a press `left` points right of where the group's @@ -281,30 +294,49 @@ pub(crate) fn strip_grab(ctx: &egui::Context, left: f32) -> Vec2 { /// Float `ids` under the pointer, held `grab` from the new group's top-left corner, and carry on /// moving them with the pointer (a tab or group dragged out of the dock or out of its group). pub(crate) fn tear(app: &mut VectorcraftApp, ctx: &egui::Context, ids: &[&'static str], active: &'static str, grab: Vec2) { + // A restored popout may retain its drag response in the cancellation frame. Do not + // immediately tear it out again after track restored the gesture's original layout. + if !gesture_allowed(ctx) { + return; + } let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; - float(&mut app.ui, ids, active, at - grab); - start(ctx, Moving::Panel(active), grab); + let original = app.ui.clone(); + let pos = at - grab; + if let Err(error) = app.run("window.panel.float", json!({"panel":active,"group":ids.len() > 1,"x":pos.x,"y":pos.y})) { + app.ui.status = error; + return; + } + start(app, ctx, Moving::Panel(active), grab); + ctx.data_mut(|data| { + if let Some(mut drag) = data.get_temp::(move_id()) { + drag.original = original; + data.insert_temp(move_id(), drag); + } + }); } /// The Tools panel's title bar was pressed (`title`, the panel's bounds `bounds`): docked, dragging /// it out of the panel floats the panel under the pointer; floating at `floating`, a drag moves it. pub(crate) fn drag_tools(app: &mut VectorcraftApp, ctx: &egui::Context, title: &egui::Response, bounds: Rect, floating: Option) { + if !gesture_allowed(ctx) { + return; + } let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; match floating { - Some(pos) if title.drag_started() => start(ctx, Moving::Tools, at - pos), + Some(pos) if title.drag_started() => start(app, ctx, Moving::Tools, at - pos), None if title.dragged() && !bounds.contains(at) => { // Held where the press grabbed the title bar. let grab = ctx.input(|i| i.pointer.press_origin()).map_or(vec2(10.0, 7.0), |p| p - bounds.min); let p = at - grab; + start(app, ctx, Moving::Tools, grab); app.ui.toolbar_pos = Some([p.x, p.y]); - start(ctx, Moving::Tools, grab); } _ => {} } } /// Where the pointer at `at` would drop `what`, and the zone that lights up for it. -fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> { +pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2) -> Option<(Drop, Rect)> { let zone = |id: Id| ctx.data(|d| d.get_temp::(id)).filter(|r| r.contains(at)); let Moving::Panel(id) = what else { return zone(tools_zone_id()).map(|r| (Drop::Dock, r)) }; let own = group_of(&app.ui, id); @@ -327,7 +359,39 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 let columns = ctx.data(|d| d.get_temp::>(columns_id())).unwrap_or_default(); for (i, g) in app.ui.floating_panels.iter().enumerate() { let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue }; - let Some(mut r) = ctx.data(|d| d.get_temp::(group_rect_id(first))) else { continue }; + let Some(mut r) = ctx + .data(|d| d.get_temp::(group_rect_id(first))) + .or_else(|| ctx.memory(|m| m.area_rect(Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))))) + else { + continue; + }; + // A visible window owns hits inside its body. Old rectangles and lower windows + // must not capture a drop through another panel or popup; edge insertion remains + // available just outside the window. Dock columns reserve their own panel space. + if g.docked.is_none() && r.contains(at) { + let area = if app.ui.docking.is_some() && !crate::panel_docking::decorated(g) { + Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())) + } else { + let lead = set_of(&app.ui, i).first().and_then(|&index| app.ui.floating_panels.get(index)).and_then(|group| group.panels.first()); + area_id(lead.map_or(first, String::as_str)) + }; + let moving_areas: Vec = moving + .iter() + .filter_map(|&index| app.ui.floating_panels.get(index).and_then(|group| group.panels.first())) + .flat_map(|panel| [area_id(panel), Id::new("vectorcraft-panel-docking").with(("floating", panel))]) + .collect(); + let owner = ctx.memory(|memory| { + let layers: Vec<_> = memory.layer_ids().collect(); + layers.into_iter().rev().find(|layer| { + !moving_areas.contains(&layer.id) + && memory.areas().is_visible(layer) + && memory.area_rect(layer.id).is_some_and(|rect| rect.contains(at)) + }) + }); + if owner.is_some_and(|layer| layer.id != area) { + continue; + } + } // In a dock column that scrolls, only the part in view: a group scrolled away takes nothing. if let Some(col) = g.docked.and_then(|d| columns.get(usize::try_from(d).ok()?.checked_sub(1)?)) { r = r.intersect(*col); @@ -368,7 +432,7 @@ fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Moving, at: Pos2 } /// Light up a drop zone: over the docked panels, under the floating ones (the moved one too). -fn highlight(ctx: &egui::Context, zone: Rect) { +pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) { let t = Tokens::get(ctx); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone"))); painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); @@ -388,10 +452,43 @@ fn insertion_line(ctx: &egui::Context, line: Rect) { painter.rect_filled(line, CornerRadius::same(2), t.accent); } +pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) { + match target { + Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect), + Drop::Stack(_) => tab_zone(ctx, rect), + Drop::Dock => highlight(ctx, rect), + } +} + /// Carry a move on (at the start of a frame, before the panels are drawn): the group or Tools panel /// follows the pointer and the zone it would drop on lights up; the release drops it there. pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { + if ctx.input(|input| input.pointer.primary_pressed()) { + ctx.data_mut(|data| data.remove::(suppressed_id())); + } + let before = app.ui.clone(); let Some(m) = ctx.data(|d| d.get_temp::(move_id())) else { return }; + if m.original.docking_generation != app.ui.docking_generation { + ctx.data_mut(|data| { + data.remove::(move_id()); + data.insert_temp(suppressed_id(), true); + }); + return; + } + if ctx.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) { + app.ui.docking = m.original.docking; + app.ui.docking_hidden = m.original.docking_hidden; + app.ui.docking_icons = m.original.docking_icons; + app.ui.floating_panels = m.original.floating_panels; + app.ui.open_panel = m.original.open_panel; + app.ui.dock_tab = m.original.dock_tab; + app.ui.toolbar_pos = m.original.toolbar_pos; + ctx.data_mut(|data| { + data.remove::(move_id()); + data.insert_temp(suppressed_id(), true); + }); + return; + } let at = ctx.input(|i| i.pointer.interact_pos()); let target = at.filter(|at| at.distance(m.from) > TRAVEL).and_then(|at| drop_target(app, ctx, m.what, at)); if ctx.input(|i| i.pointer.primary_down()) { @@ -409,6 +506,9 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { Moving::Tools => {} } } + if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) { + app.ui.status = error; + } match target { Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line), Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs), @@ -441,6 +541,9 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { Drop::Above(onto) => attach(&mut app.ui, &set, onto, false), Drop::Column(slot) => dock_column(&mut app.ui, &set, slot), } + if let Err(error) = crate::panel_docking::reconcile_native(app, before) { + app.ui.status = error; + } } /// What a press on a floating or docked group did. @@ -490,11 +593,12 @@ fn docked(ui: &UiState, set: &[usize]) -> bool { /// the dock (Tab, Presentation Mode). Sets docked as dock columns are drawn by [`docked_columns`]. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.ui.floating_panels.is_empty() || !app.ui.dock || app.ui.screen_mode >= 3 { + crate::panel_docking::finish_native_drop(app, ctx); return; } let mut action = None; for gi in 0..app.ui.floating_panels.len() { - if leads(&app.ui, gi) { + if leads(&app.ui, gi) && (app.ui.docking.is_none() || app.ui.floating_panels.get(gi).is_some_and(crate::panel_docking::decorated)) { let set = set_of(&app.ui, gi); if !docked(&app.ui, &set) { show_set(app, ctx, &set, &mut action); @@ -502,10 +606,18 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } apply(app, ctx, action); + crate::panel_docking::finish_native_drop(app, ctx); } /// Carry out what a press on a floating or docked group did. fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) { + if action.is_none() { + return; + } + if matches!(action, Some(Action::Move(..) | Action::Detach(..) | Action::Tear(..) | Action::Undock(..))) && !gesture_allowed(ctx) { + return; + } + let before = app.ui.clone(); match action { Some(Action::Activate(gi, k)) => { if let Some(g) = app.ui.floating_panels.get_mut(gi) @@ -531,7 +643,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) set_pos(&mut app.ui, gi, [pos.x, pos.y]); } if let Some(at) = ctx.input(|i| i.pointer.interact_pos()) { - start(ctx, Moving::Panel(id), at - pos); + start(app, ctx, Moving::Panel(id), at - pos); } } Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left), @@ -547,10 +659,19 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) } } FloatingPanels::tidy(&mut app.ui.floating_panels); - start(ctx, Moving::Panel(id), grab); + start(app, ctx, Moving::Panel(id), grab); } None => {} } + if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) { + app.ui.status = error; + } + ctx.data_mut(|data| { + if let Some(mut drag) = data.get_temp::(move_id()) { + drag.original = before; + data.insert_temp(move_id(), drag); + } + }); } /// Take group `gi` out of its set (or its dock column) under the pointer, its tab strip having @@ -566,7 +687,7 @@ fn detach(app: &mut VectorcraftApp, ctx: &egui::Context, gi: usize, left: f32) { g.pos = [p.x, p.y]; } FloatingPanels::tidy(&mut app.ui.floating_panels); - start(ctx, Moving::Panel(id), grab); + start(app, ctx, Moving::Panel(id), grab); } /// Put the groups `set` in the dock as a new column at `slot` (1: next to the icon column, counting @@ -655,7 +776,13 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec }); rects.push(panel.response.rect); } - ui.ctx().data_mut(|d| d.insert_temp(columns_id(), rects.clone())); + ui.ctx().data_mut(|d| { + let icons = d.get_temp::(icons_rect_id()); + if let Some(left) = icons.map(|r| rects.iter().map(|r| r.left()).fold(r.left(), f32::min)) { + d.insert_temp(dock::dock_left_id(), left); + } + d.insert_temp(columns_id(), rects.clone()); + }); let ctx = ui.ctx().clone(); apply(app, &ctx, action); rects @@ -753,18 +880,13 @@ fn draw_groups( let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.panel_darker); let lone = g.ids.len() == 1; - let mut left = strip.left(); - for (j, &id) in g.ids.iter().enumerate() { - let label = panel(id).map_or(id, |(_, label)| label); - let color = if id == g.active { t.text_strong } else { t.text_dim }; - let galley = ui.painter().layout_no_wrap(tl!(label).to_string(), theme::semibold(12.0), color); - let r = Rect::from_min_size(pos2(left, strip.top()), vec2(galley.size().x + 24.0, STRIP)); - left = r.right(); - let resp = ui.interact(r, ui.id().with(("tab", id)), Sense::click_and_drag()); - if id == g.active && !g.collapsed { - ui.painter().rect_filled(r, 0.0, t.panel); - } - ui.painter().galley(pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text); + let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); + let tab_area = Rect::from_min_max(strip.min, pos2((menu.left() - 4.0).max(strip.left()), strip.bottom())); + let names: Vec<_> = g.ids.iter().map(|&id| (id, panel(id).map_or(id, |(_, label)| label))).collect(); + let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0); + let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false); + for (j, resp) in out.responses { + let Some(&id) = g.ids.get(j) else { continue }; if resp.double_clicked() { *action = Some(Action::Collapse(g.gi)); } else if resp.clicked() { @@ -775,11 +897,13 @@ fn draw_groups( None => Action::Detach(g.gi, strip.left()), }); } else if !lone && resp.dragged() && pointer.is_some_and(|p| !strip.expand(4.0).contains(p)) { - *action = Some(Action::Tear(id, r.left())); + *action = Some(Action::Tear(id, resp.rect.left())); } } - let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); - let rest = Rect::from_min_max(pos2(left, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); + if let Some(j) = out.picked { + *action = Some(Action::Activate(g.gi, j)); + } + let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); if rest.width() > 0.0 { let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); if resp.drag_started() { @@ -1003,7 +1127,9 @@ mod tests { fn frame(&mut self, events: Vec) { self.time += 0.05; let screen = Rect::from_min_size(Pos2::ZERO, self.screen); - let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), events, ..Default::default() }; + let focused = + events.iter().rev().find_map(|event| if let Event::WindowFocused(focused) = event { Some(*focused) } else { None }).unwrap_or(true); + let raw = egui::RawInput { time: Some(self.time), screen_rect: Some(screen), focused, events, ..Default::default() }; let app = &mut self.app; self.ctx .run_ui(raw, |ui| { @@ -1050,11 +1176,22 @@ mod tests { /// The floating group whose first panel is `first`, as drawn. fn group(&self, first: &str) -> Rect { - self.ctx.memory(|m| m.area_rect(area_id(first))).unwrap() + let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated); + let id = if self.app.ui.docking.is_some() && !native { + Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())) + } else { + area_id(first) + }; + self.ctx.memory(|m| m.area_rect(id)).unwrap() } /// A point on a floating group's title bar (left of its ×). fn title(&self, first: &str) -> Pos2 { + let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated); + if self.app.ui.docking.is_some() && !native { + let rect = self.group(first); + return rect.left_top() + vec2(6.0, 16.0); + } let r = self.group(first); pos2(r.left() + 30.0, r.top() + 1.0 + TITLE / 2.0) } @@ -1068,15 +1205,29 @@ mod tests { r } + fn shared_tab(&self, panel: &str) -> Rect { + self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect + } + /// The dock's tabs, left to right. fn dock_tabs(&self) -> Vec { + if let Some(layout) = &self.app.ui.docking { + let root = craft_ui::docking::Layout { root: layout.root.clone(), floating: Vec::new() }; + return root + .panels() + .into_iter() + .filter_map(|panel| { + self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect) + }) + .collect(); + } let dock = self.temp_rect(dock_rect_id()); self.widgets(|r| r.height() == 32.0 && dock.contains_rect(r)) } /// The icon column's panel icons, top to bottom. fn icons(&self) -> Vec { - let dock = self.temp_rect(dock_rect_id()); + let dock = self.temp_rect(if self.app.ui.docking.is_some() { icons_rect_id() } else { dock_rect_id() }); self.widgets(|r| r.size() == vec2(30.0, 30.0) && dock.contains_rect(r)) } @@ -1089,30 +1240,19 @@ mod tests { fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() { let mut h = Harness::new(); let tabs = h.dock_tabs(); - assert_eq!(tabs.len(), 3, "Properties, Layers, Libraries: {tabs:?}"); + assert_eq!(tabs.len(), 3); let to = pos2(600.0, 400.0); h.drag(tabs[1].center(), to); - assert_eq!(h.groups(), [["layers"]], "Layers floats"); - assert!(h.group("layers").contains(to), "under the pointer: {:?}", h.group("layers")); - assert_eq!(h.dock_tabs().len(), 2, "its tab left the dock"); + assert_eq!(h.groups(), [["layers"]]); + assert!(h.group("layers").contains(to)); + assert_eq!(h.dock_tabs().len(), 2); assert_eq!(h.app.ui.dock_tab, DockTab::Properties); - // A click on its title bar moves nothing, even over the dock. - let dock = h.temp_rect(dock_rect_id()); - h.app.run("window.panel.float", json!({"panel": "layers", "x": dock.left() + 10.0, "y": 300})).unwrap(); - h.settle(); h.click(h.title("layers")); - assert_eq!(h.groups(), [["layers"]], "a click doesn't dock it"); - // Dragged by its title bar onto the dock, the dock lights up, and the release docks it. - h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 300})).unwrap(); - h.settle(); - let over = dock.center(); - h.hold(h.title("layers"), over); - assert!(h.group("layers").contains(over), "the group follows the pointer"); - let target = drop_target(&h.app, &h.ctx, Moving::Panel("layers"), over); - assert_eq!(target, Some((Drop::Dock, dock)), "the dock is the drop zone, lit whole: its tab comes back"); - h.release(over); - assert!(h.groups().is_empty(), "docked: {:?}", h.groups()); - assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers), "back in the dock, shown"); + assert_eq!(h.groups(), [["layers"]], "a click only activates the floating tab"); + let over = h.shared_tab("properties").center(); + h.drag(h.title("layers"), over); + assert!(h.groups().is_empty()); + assert_eq!((h.dock_tabs().len(), h.app.ui.dock_tab), (3, DockTab::Layers)); } #[test] @@ -1127,7 +1267,9 @@ mod tests { assert!(column.width() < 40.0, "only the icon column is left: {column:?}"); // Its × puts them back. let g = h.group("properties"); - h.click(pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0)); + let close = + if h.app.ui.docking.is_some() { pos2(g.right() - 36.0, g.top() + 16.0) } else { pos2(g.right() - 11.0, g.top() + 1.0 + TITLE / 2.0) }; + h.click(close); assert!(h.groups().is_empty()); assert_eq!(h.dock_tabs().len(), 3); } @@ -1143,32 +1285,40 @@ mod tests { assert_eq!(count, crate::state::ICON_PANEL_GROUPS.iter().map(|g| g.len()).sum::(), "every group's icons are drawn"); h.drag(icons[2].center(), pos2(500.0, 250.0)); assert_eq!(h.groups(), [["swatches"]]); - assert_eq!(h.icons().len(), count - 1, "its icon left the column"); - h.drag(h.icons()[0].center(), pos2(500.0, 650.0)); + assert_eq!(h.icons().len(), count - 1); + let separate = pos2(900.0, 650.0); + assert!(!h.group("swatches").contains(separate)); + h.drag(h.icons()[0].center(), separate); assert_eq!(h.groups(), [["swatches"], ["color"]]); - // Color's title bar dropped on the Swatches group's tabs stacks them. - let onto = h.group("swatches").left_top() + vec2(150.0, 1.0 + TITLE + STRIP / 2.0); - h.hold(h.title("color"), onto); - assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), onto).map(|t| t.0), Some(Drop::Stack("swatches"))); - h.release(onto); + let onto = h.shared_tab("swatches").right_center() - vec2(3.0, 0.0); + h.drag(h.title("color"), onto); assert_eq!(h.groups(), [["swatches", "color"]]); - assert_eq!(h.app.ui.floating_panels[0].active, 1, "showing the panel dropped on it"); - // A click shows a tab; a tab dragged out of the strip floats on its own again. - let g = h.group("swatches"); - let tabs = h.widgets(|r| r.height() == STRIP && g.contains_rect(r)); - assert_eq!(tabs.len(), 3, "two tabs and the strip right of them: {tabs:?}"); - h.click(tabs[0].center()); + assert_eq!(h.app.ui.floating_panels[0].active, 1); + h.click(h.shared_tab("swatches").center()); assert_eq!(h.app.ui.floating_panels[0].active, 0); let to = pos2(900.0, 500.0); - h.drag(tabs[1].center(), to); + h.drag(h.shared_tab("color").center(), to); assert_eq!(h.groups(), [["swatches"], ["color"]]); assert!(h.group("color").contains(to)); - // Window › Color shows it where it floats; Dock Panel puts it back. assert_eq!(h.app.run("window.panel", json!({"panel": "Color"})).unwrap(), json!({"floating": ["color"]})); assert_eq!(crate::menus::checked(&h.app, "window.panel", &json!({"panel": "color"})), Some(true)); h.app.run("window.panel.dock", json!({"panel": "color"})).unwrap(); h.settle(); + assert!(h.groups().iter().all(|group| !group.contains(&"color"))); assert_eq!(h.icons().len(), count - 1, "Color is back in the column"); + assert_eq!(h.app.ui.open_panel.as_deref(), Some("color")); + } + + #[test] + fn an_icon_panel_can_be_dropped_into_another_docked_tab_group() { + let mut h = Harness::new(); + h.drag(h.icons()[0].center(), pos2(500.0, 400.0)); + assert_eq!(h.groups(), [["color"]]); + h.drag(h.shared_tab("color").center(), h.shared_tab("layers").center()); + assert!(h.groups().is_empty()); + assert!(h.app.ui.docking.as_ref().unwrap().contains(&"color".into())); + assert!(h.app.ui.open_panel.is_none(), "the actual shared group owns Color"); + assert_eq!(h.dock_tabs().len(), 4); } #[test] @@ -1204,6 +1354,68 @@ mod tests { assert!(h.groups().is_empty()); } + #[test] + fn popped_out_title_drag_cancels_on_escape_focus_loss_and_workspace_change() { + for reason in ["escape", "focus", "workspace"] { + let mut h = Harness::new(); + h.app.run("window.panel", json!({"panel":"swatches"})).unwrap(); + h.settle(); + let original = h.app.ui.clone(); + let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap(); + let to = pos2(500.0, 400.0); + h.hold(flyout.left_top() + vec2(20.0, 13.0), to); + assert!(h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"swatches".into()))); + match reason { + "escape" => h.frame(vec![Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: Default::default(), + }]), + "focus" => h.frame(vec![Event::WindowFocused(false)]), + _ => { + crate::workspaces::apply(&mut h.app.ui, &crate::workspaces::Workspace::default()); + h.frame(vec![Event::PointerMoved(pos2(650.0, 450.0))]); + } + } + assert!(h.ctx.data(|d| d.get_temp::(move_id())).is_none(), "{reason}: stale gesture removed"); + assert!(h.app.ui.docking.is_none(), "{reason}: original/selected workspace retained"); + assert!(h.app.ui.floating_panels.is_empty(), "{reason}: no stale floating group"); + if reason != "workspace" { + assert_eq!(h.app.ui.open_panel, original.open_panel); + } + if reason == "focus" { + h.frame(vec![Event::WindowFocused(true)]); + } + for step in 1..=3 { + h.frame(vec![Event::PointerMoved(to + vec2(15.0 * step as f32, 8.0))]); + assert!(h.app.ui.floating_panels.is_empty(), "{reason}: canceled drag stays canceled on held frame {step}"); + } + h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: true, modifiers: Default::default() }]); + assert!(h.app.ui.floating_panels.is_empty(), "{reason}: secondary press does not restart primary gesture"); + h.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Secondary, pressed: false, modifiers: Default::default() }]); + h.release(to); + assert!(h.app.ui.floating_panels.is_empty(), "{reason}: release does not restart canceled gesture"); + } + } + + #[test] + fn docked_tools_drag_cancellation_restores_the_docked_origin() { + let mut h = Harness::new(); + let zone = h.temp_rect(tools_zone_id()); + let title = zone.left_top() + vec2(30.0, 7.0); + let to = pos2(400.0, 300.0); + h.hold(title, to); + assert!(h.app.ui.toolbar_pos.is_some()); + h.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert_eq!(h.app.ui.toolbar_pos, None, "cancel restores docked Tools origin"); + h.frame(vec![Event::PointerMoved(to + vec2(30.0, 20.0))]); + assert_eq!(h.app.ui.toolbar_pos, None); + h.release(to); + assert_eq!(h.app.ui.toolbar_pos, None); + } + #[test] fn the_tools_panel_floats_by_its_title_bar_and_docks_on_the_left_edge() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 39ce8fc0..345eb587 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -695,6 +695,14 @@ Ocean Ripple… Vlnky na hladině… Rename Data Set Přejmenovat sadu dat Rename Variable Přejmenovat proměnnou +# Shared panel docking + Activate Panel Aktivovat panel + Change Panel Layout Změnit rozložení panelů + Close Panel Zavřít panel + Move Panel Přesunout panel + Group with Seskupit s + Resize panels Změnit velikost panelů + Resize panel window Změnit velikost okna panelu # Interactive Revolve controls. Lighting Osvětlení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d4e97fdb..513d77e5 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4007,6 +4007,31 @@ theme Appearance Erscheinungsbild Define a variable first Definieren Sie zuerst eine Variable Text Text Visibility Sichtbarkeit + + Activate Panel Bedienfeld aktivieren + Change Panel Layout Bedienfeldanordnung ändern + Close Panel Bedienfeld schließen + Move Panel Bedienfeld verschieben + Group with Gruppieren mit + Resize panel window Bedienfeldfenster skalieren + Resize panels Bedienfelder skalieren +@msg Invalid panel layout; restored the default dock Ungültige Bedienfeldanordnung; Standarddock wiederhergestellt +@msg action is required Eine Aktion ist erforderlich +@msg anchor must name a registered panel Der Anker muss ein registriertes Bedienfeld benennen +@msg before must name a panel before muss ein Bedienfeld benennen +@msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad +@msg group must be true or false group muss true oder false sein +@msg move requires a destination panel Das Verschieben erfordert ein Zielbedienfeld +@msg move requires an existing destination panel Das Verschieben erfordert ein vorhandenes Zielbedienfeld +@msg onto must name a floating panel onto muss ein schwebendes Bedienfeld benennen +@msg panel group is empty Die Bedienfeldgruppe ist leer +@msg panel must name a registered panel panel muss ein registriertes Bedienfeld benennen +@msg saved panel layout is invalid; reset the workspace first Die gespeicherte Bedienfeldanordnung ist ungültig; zuerst den Arbeitsbereich zurücksetzen +@msg x and y must be provided together x und y müssen zusammen angegeben werden +@msg zone must be a string zone muss eine Zeichenfolge sein +@msg zone must be tab, center, left, right, top or bottom zone muss tab, center, left, right, top oder bottom sein +@msg {key} must be a finite coordinate {key} muss eine endliche Koordinate sein +@msg invalid restored panel group Ungültige wiederhergestellte Bedienfeldgruppe # 3D and Materials › Revolve: its options dialog and gizmos. Ambient Umgebungslicht Axis side Achsenseite @@ -4060,3 +4085,6 @@ theme Appearance Erscheinungsbild Stroke Pressure Strichdruck Right Diagonal Diagonal nach rechts Left Diagonal Diagonal nach links +@msg invalid native panel set Ungültiger nativer Bedienfeldsatz +@msg missing panel layout Bedienfeldanordnung fehlt +@msg unsupported native docking operation Nicht unterstützte native Docking-Operation diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 40790548..dd147da7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4047,6 +4047,31 @@ axis Both Ambos Define a variable first Defina primero una variable Text Texto Visibility Visibilidad +# Shared panel docking + Activate Panel Activar panel + Change Panel Layout Cambiar distribución de paneles + Close Panel Cerrar panel + Move Panel Mover panel + Group with Agrupar con + Resize panels Cambiar tamaño de los paneles + Resize panel window Cambiar tamaño de la ventana del panel +@msg Invalid panel layout; restored the default dock La distribución de paneles no es válida; se restauró el anclaje predeterminado +@msg action is required Se requiere action +@msg anchor must name a registered panel anchor debe indicar un panel registrado +@msg before must name a panel before debe indicar un panel +@msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga +@msg group must be true or false group debe ser true o false +@msg move requires a destination panel Para mover se requiere un panel de destino +@msg move requires an existing destination panel Para mover se requiere un panel de destino existente +@msg onto must name a floating panel onto debe indicar un panel flotante +@msg panel group is empty El grupo de paneles está vacío +@msg panel must name a registered panel panel debe indicar un panel registrado +@msg saved panel layout is invalid; reset the workspace first La distribución guardada de paneles no es válida; primero restablece el espacio de trabajo +@msg zone must be a string zone debe ser una cadena de texto +@msg zone must be tab, center, left, right, top or bottom zone debe ser tab, center, left, right, top o bottom +@msg {key} must be a finite coordinate {key} debe ser una coordenada finita +@msg x and y must be provided together x e y deben proporcionarse juntos +@msg invalid restored panel group El grupo de paneles restaurado no es válido # Revolve profile guidance. @@ -4105,3 +4130,6 @@ lighting Light Luz Vertical Guide Guía vertical Horizontal Guide Guía horizontal Move Guide to Layer Mover guía a capa +@msg invalid native panel set Conjunto nativo de paneles no válido +@msg missing panel layout Falta la distribución de paneles +@msg unsupported native docking operation Operación nativa de anclaje no admitida diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9a7373ae..12d62d3a 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4059,6 +4059,31 @@ effect Soft Doux Define a variable first Définissez d’abord une variable Text Texte Visibility Visibilité +# Shared panel docking + Activate Panel Activer le panneau + Change Panel Layout Modifier la disposition des panneaux + Close Panel Fermer le panneau + Move Panel Déplacer le panneau + Group with Regrouper avec + Resize panels Redimensionner les panneaux + Resize panel window Redimensionner la fenêtre du panneau +@msg Invalid panel layout; restored the default dock La disposition des panneaux est invalide ; la disposition ancrée par défaut a été restaurée +@msg action is required action est requis +@msg anchor must name a registered panel anchor doit désigner un panneau enregistré +@msg before must name a panel before doit désigner un panneau +@msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long +@msg group must be true or false group doit être true ou false +@msg move requires a destination panel Le déplacement nécessite un panneau de destination +@msg move requires an existing destination panel Le déplacement nécessite un panneau de destination existant +@msg onto must name a floating panel onto doit désigner un panneau flottant +@msg panel group is empty Le groupe de panneaux est vide +@msg panel must name a registered panel panel doit désigner un panneau enregistré +@msg saved panel layout is invalid; reset the workspace first La disposition enregistrée des panneaux est invalide ; réinitialisez d’abord l’espace de travail +@msg zone must be a string zone doit être une chaîne de caractères +@msg zone must be tab, center, left, right, top or bottom zone doit être tab, center, left, right, top ou bottom +@msg {key} must be a finite coordinate {key} doit être une coordonnée finie +@msg x and y must be provided together x et y doivent être fournis ensemble +@msg invalid restored panel group Le groupe de panneaux restauré est invalide # Interactive Revolve controls. Lighting Éclairage @@ -4115,3 +4140,6 @@ lighting Light Lumière Vertical Guide Repère vertical Horizontal Guide Repère horizontal Move Guide to Layer Déplacer le repère vers le calque +@msg invalid native panel set Ensemble natif de panneaux invalide +@msg missing panel layout Disposition des panneaux manquante +@msg unsupported native docking operation Opération native d’ancrage non prise en charge diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d8c9a6b1..e693ee6d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4049,6 +4049,31 @@ effect Regular Normale Define a variable first Definisci prima una variabile Text Testo Visibility Visibilità +# Shared panel docking + Activate Panel Attiva pannello + Change Panel Layout Modifica disposizione dei pannelli + Close Panel Chiudi pannello + Move Panel Sposta pannello + Group with Raggruppa con + Resize panels Ridimensiona pannelli + Resize panel window Ridimensiona finestra del pannello +@msg Invalid panel layout; restored the default dock La disposizione dei pannelli non è valida; è stato ripristinato l’ancoraggio predefinito +@msg action is required action è obbligatorio +@msg anchor must name a registered panel anchor deve indicare un pannello registrato +@msg before must name a panel before deve indicare un pannello +@msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo +@msg group must be true or false group deve essere true o false +@msg move requires a destination panel Lo spostamento richiede un pannello di destinazione +@msg move requires an existing destination panel Lo spostamento richiede un pannello di destinazione esistente +@msg onto must name a floating panel onto deve indicare un pannello mobile +@msg panel group is empty Il gruppo di pannelli è vuoto +@msg panel must name a registered panel panel deve indicare un pannello registrato +@msg saved panel layout is invalid; reset the workspace first La disposizione salvata dei pannelli non è valida; reimposta prima l’area di lavoro +@msg zone must be a string zone deve essere una stringa +@msg zone must be tab, center, left, right, top or bottom zone deve essere tab, center, left, right, top o bottom +@msg {key} must be a finite coordinate {key} deve essere una coordinata finita +@msg x and y must be provided together x e y devono essere forniti insieme +@msg invalid restored panel group Il gruppo di pannelli ripristinato non è valido # Revolve profile guidance. @@ -4107,3 +4132,6 @@ lighting Light Luce Vertical Guide Guida verticale Horizontal Guide Guida orizzontale Move Guide to Layer Sposta guida nel livello +@msg invalid native panel set Gruppo nativo di pannelli non valido +@msg missing panel layout Disposizione dei pannelli mancante +@msg unsupported native docking operation Operazione nativa di ancoraggio non supportata diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9d691732..9a133989 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4042,6 +4042,32 @@ effect Vertical 垂直 Define a variable first 先に変数を定義してください Text テキスト Visibility 表示状態 +# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# Shared panel docking + Activate Panel パネルをアクティブにする + Change Panel Layout パネルの配置を変更 + Close Panel パネルを閉じる + Move Panel パネルを移動 + Group with グループ化先 + Resize panels パネルのサイズを変更 + Resize panel window パネルウィンドウのサイズを変更 +@msg invalid restored panel group 復元されたパネルグループが無効です +@msg Invalid panel layout; restored the default dock パネル配置が無効です。既定のドックを復元しました +@msg action is required action を指定してください +@msg anchor must name a registered panel anchor に登録済みパネルを指定してください +@msg before must name a panel before にパネルを指定してください +@msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています +@msg group must be true or false group は true または false にしてください +@msg move requires a destination panel 移動先のパネルを指定してください +@msg move requires an existing destination panel 移動先には既存のパネルを指定してください +@msg onto must name a floating panel onto にフローティングパネルを指定してください +@msg panel group is empty パネルグループが空です +@msg panel must name a registered panel panel に登録済みパネルを指定してください +@msg saved panel layout is invalid; reset the workspace first 保存されたパネル配置が無効です。先にワークスペースをリセットしてください +@msg x and y must be provided together x と y は両方指定してください +@msg zone must be a string zone は文字列にしてください +@msg zone must be tab, center, left, right, top or bottom zone は tab、center、left、right、top、bottom のいずれかにしてください +@msg {key} must be a finite coordinate {key} は有限の座標にしてください # Revolve profile guidance. @@ -4100,3 +4126,6 @@ lighting Light ライト Vertical Guide 垂直ガイド Horizontal Guide 水平ガイド Move Guide to Layer ガイドをレイヤーへ移動 +@msg invalid native panel set ネイティブパネルセットが無効です +@msg missing panel layout パネル配置がありません +@msg unsupported native docking operation ネイティブドッキング操作は未対応です diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b5039cb2..e4befb68 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2756,6 +2756,14 @@ Ocean Ripple… Ondulação oceânica… Rename Data Set Renomear conjunto de dados Rename Variable Renomear variável +# Shared panel docking + Activate Panel Ativar Painel + Change Panel Layout Alterar Layout dos Painéis + Close Panel Fechar Painel + Move Panel Mover Painel + Group with Agrupar com + Resize panels Redimensionar painéis + Resize panel window Redimensionar janela do painel # Interactive Revolve controls. Lighting Iluminação diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 95591e31..e805d0af 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4045,6 +4045,31 @@ axis Both Оба Define a variable first Сначала определите переменную Text Текст Visibility Видимость +# Shared panel docking + Activate Panel Активировать панель + Change Panel Layout Изменить расположение панелей + Close Panel Закрыть панель + Move Panel Переместить панель + Group with Сгруппировать с + Resize panels Изменить размер панелей + Resize panel window Изменить размер окна панели +@msg Invalid panel layout; restored the default dock Недопустимое расположение панелей; восстановлено расположение по умолчанию +@msg action is required Необходимо указать action +@msg anchor must name a registered panel anchor должен указывать зарегистрированную панель +@msg before must name a panel before должен указывать панель +@msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений +@msg group must be true or false group должен иметь значение true или false +@msg move requires a destination panel Для перемещения требуется целевая панель +@msg move requires an existing destination panel Для перемещения требуется существующая целевая панель +@msg onto must name a floating panel onto должен указывать плавающую панель +@msg panel group is empty Группа панелей пуста +@msg panel must name a registered panel panel должен указывать зарегистрированную панель +@msg saved panel layout is invalid; reset the workspace first Сохранённое расположение панелей недопустимо; сначала сбросьте рабочую среду +@msg zone must be a string zone должен быть строкой +@msg zone must be tab, center, left, right, top or bottom zone должен иметь значение tab, center, left, right, top или bottom +@msg {key} must be a finite coordinate {key} должен быть конечной координатой +@msg x and y must be provided together x и y необходимо указывать вместе +@msg invalid restored panel group Восстановленная группа панелей недопустима # Revolve profile guidance. @@ -4103,3 +4128,6 @@ lighting Light Свет Vertical Guide Вертикальная направляющая Horizontal Guide Горизонтальная направляющая Move Guide to Layer Переместить направляющую на слой +@msg invalid native panel set Недопустимый собственный набор панелей +@msg missing panel layout Отсутствует расположение панелей +@msg unsupported native docking operation Неподдерживаемая собственная операция прикрепления diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3410095a..370f820e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4030,6 +4030,31 @@ axis Both Обидві Define a variable first Спочатку визначте змінну Text Текст Visibility Видимість +# Shared panel docking + Activate Panel Активувати панель + Change Panel Layout Змінити розташування панелей + Close Panel Закрити панель + Move Panel Перемістити панель + Group with Згрупувати з + Resize panels Змінити розмір панелей + Resize panel window Змінити розмір вікна панелі +@msg Invalid panel layout; restored the default dock Неприпустиме розташування панелей; відновлено типове розташування +@msg action is required Потрібно вказати action +@msg anchor must name a registered panel anchor має вказувати зареєстровану панель +@msg before must name a panel before має вказувати панель +@msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів +@msg group must be true or false group має мати значення true або false +@msg move requires a destination panel Для переміщення потрібна цільова панель +@msg move requires an existing destination panel Для переміщення потрібна наявна цільова панель +@msg onto must name a floating panel onto має вказувати плаваючу панель +@msg panel group is empty Група панелей порожня +@msg panel must name a registered panel panel має вказувати зареєстровану панель +@msg saved panel layout is invalid; reset the workspace first Збережене розташування панелей неприпустиме; спочатку скиньте робоче середовище +@msg zone must be a string zone має бути рядком +@msg zone must be tab, center, left, right, top or bottom zone має мати значення tab, center, left, right, top або bottom +@msg {key} must be a finite coordinate {key} має бути скінченною координатою +@msg x and y must be provided together x та y потрібно вказувати разом +@msg invalid restored panel group Відновлена група панелей неприпустима # Interactive Revolve controls. Lighting Освітлення @@ -4086,3 +4111,6 @@ lighting Light Світло Vertical Guide Вертикальна напрямна Horizontal Guide Горизонтальна напрямна Move Guide to Layer Перемістити напрямну на шар +@msg invalid native panel set Неприпустимий власний набір панелей +@msg missing panel layout Відсутнє розташування панелей +@msg unsupported native docking operation Непідтримувана власна операція прикріплення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e293f0b9..576ea511 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3670,6 +3670,14 @@ effect Vertical 垂直 Define a variable first 请先定义一个变量 Text 文本 Visibility 可见性 +# Shared panel docking + Activate Panel 激活面板 + Change Panel Layout 更改面板布局 + Close Panel 关闭面板 + Move Panel 移动面板 + Group with 组合到 + Resize panels 调整面板大小 + Resize panel window 调整面板窗口大小 # Revolve profile guidance. diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 78446596..45e53634 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3451,6 +3451,14 @@ effect Soft 柔和 Define a variable first 請先定義一個變數 Text 文字 Visibility 可見性 +# Shared panel docking + Activate Panel 啟用面板 + Change Panel Layout 變更面板配置 + Close Panel 關閉面板 + Move Panel 移動面板 + Group with 群組至 + Resize panels 調整面板大小 + Resize panel window 調整面板視窗大小 # Revolve profile guidance. diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index be183f10..fe00cbbb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1,28 +1,19 @@ //! Icon lookup and drawing (SVGs rasterized by egui_extras, tinted with theme colours). -use std::collections::HashMap; use std::sync::OnceLock; +use craft_ui::icons::{SvgIconSet, SvgStyle}; use egui::{Color32, ImageSource, Rect, Ui, Vec2}; -fn table() -> &'static HashMap<&'static str, &'static [u8]> { - static T: OnceLock> = OnceLock::new(); +fn table() -> &'static SvgIconSet { + static T: OnceLock = OnceLock::new(); T.get_or_init(|| { - crate::icon_data::ICONS - .iter() - .map(|(n, b)| { - // Icons use currentColor; render white and tint at draw time. Slightly thinner strokes - // than Lucide's default read closer to Illustrator's glyph weight. - let s = String::from_utf8_lossy(b).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\""); - let leaked: &'static [u8] = Box::leak(s.into_bytes().into_boxed_slice()); - (*n, leaked) - }) - .collect() + SvgIconSet::new(crate::icon_data::ICONS, SvgStyle { current_color: "white", stroke_width: Some("1.6") }, "bytes://icon/", "square-dashed") }) } pub fn exists(name: &str) -> bool { - table().contains_key(name) + table().contains(name) } /// Map tool-catalogue icon names to SVG files. @@ -102,7 +93,7 @@ pub fn tool_icon(name: &str) -> &'static str { "tool-zoom" => "zoom-in", other => { if exists(other) { - table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed") + table().canonical_name(other).unwrap_or("square-dashed") } else { "square-dashed" } @@ -111,8 +102,7 @@ pub fn tool_icon(name: &str) -> &'static str { } pub fn source(name: &str) -> ImageSource<'static> { - let bytes = table().get(name).or_else(|| table().get("square-dashed")).copied().unwrap_or(&[]); - ImageSource::Bytes { uri: format!("bytes://icon/{name}.svg").into(), bytes: egui::load::Bytes::Static(bytes) } + table().source(name) } /// Paint icon `name` into `rect` tinted with `tint`. @@ -126,3 +116,33 @@ pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response paint(ui, name, rect, tint); resp } + +#[cfg(test)] +mod tests { + use super::*; + + /// Characterize the pre-extraction loader against every embedded asset, including custom + /// icons. A shared-crate update must not silently change our stroke weight or cache keys. + #[test] + fn shared_catalog_preserves_existing_icons() { + for (name, original) in crate::icon_data::ICONS { + let expected = String::from_utf8_lossy(original).replace("currentColor", "white").replace("stroke-width=\"2\"", "stroke-width=\"1.6\""); + let ImageSource::Bytes { uri, bytes } = source(name) else { panic!("expected SVG bytes") }; + assert_eq!(uri.as_ref(), format!("bytes://icon/{name}.svg")); + assert_eq!(bytes.as_ref(), expected.as_bytes(), "{name}"); + assert!(exists(name)); + assert_eq!(tool_icon(name), *name); + } + } + + #[test] + fn shared_catalog_preserves_fallback_and_tool_aliases() { + let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") }; + let ImageSource::Bytes { bytes: fallback, .. } = source("square-dashed") else { panic!("expected SVG bytes") }; + assert_eq!(uri.as_ref(), "bytes://icon/missing-icon.svg"); + assert_eq!(bytes.as_ref(), fallback.as_ref()); + assert!(!exists("missing-icon")); + assert_eq!(tool_icon("missing-icon"), "square-dashed"); + assert_eq!(tool_icon("tool-selection"), "dc-selection"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index be5c79ec..bc89297c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,7 @@ pub mod io; pub mod menus; pub mod native_menu; pub mod palette; +mod panel_docking; pub mod panels; pub mod picks; pub mod place; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d2b6af8b..74728ebe 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -256,6 +256,20 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.taskBar.reset", "Reset Bar Position", "", "{} unpin the Contextual Task Bar and put it back under the selection"), ("window.dock", "Panels", "Tab", "{} show/hide all panels"), ("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"), + ( + "window.panel.layout", + "Change Panel Layout", + "", + "{action} serialized docking action, including floating geometry, divider sizes and accordion state", + ), + ( + "window.panel.move", + "Move Panel", + "", + "{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} group, split or reorder a panel", + ), + ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), + ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), ( "window.panel.float", "Float Panel", @@ -843,6 +857,9 @@ pub(crate) fn opt_bool(p: &Value, key: &str) -> Result, String> { /// Handle a UI command. `None` = not a UI command (the engine handles it). pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { + if let Some(result) = crate::panel_docking::command(app, id, p) { + return Some(result); + } if id == "app.language" { let lang = p.get("lang").and_then(Value::as_str).unwrap_or(""); let value = if lang.eq_ignore_ascii_case("auto") { @@ -1449,6 +1466,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { let panel = p.get("panel").and_then(Value::as_str).unwrap_or(""); let canonical = normalize_panel(panel); match canonical.and_then(DockTab::from_id) { + _ if app.ui.docking.is_some() => canonical.is_some_and(|id| { + app.ui.open_panel.as_deref() == Some(id) || app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) + }), _ if canonical.is_some_and(|id| crate::floating::group_of(&app.ui, id).is_some()) => true, Some(tab) if !app.ui.dock_collapsed => crate::floating::shown_tab(&app.ui) == Some(tab), _ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)), diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs new file mode 100644 index 00000000..df21317e --- /dev/null +++ b/crates/ui-egui/src/panel_docking.rs @@ -0,0 +1,991 @@ +//! Application-owned panel IDs and commands around the shared docking layout. + +use craft_ui::docking::{Action, Floating, Layout, Node, Placement, Zone}; +use serde_json::{Value, json}; + +use crate::VectorcraftApp; + +#[derive(Clone, Default)] +struct LegacyDrag { + layout: Option>, + open_panel: Option, + floating: Vec, + icons: std::collections::BTreeSet, +} + +#[derive(Clone)] +struct RootGroupDrag { + panel: String, + layout: Layout, + hidden: std::collections::BTreeMap>, + generation: u64, +} + +fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + let key = egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"); + let previous = ctx.data_mut(|data| data.remove_temp::(key)); + if let Some(previous) = previous { + app.ui.open_panel = previous.open_panel; + app.ui.floating_panels = previous.floating; + app.ui.docking = previous.layout; + app.ui.docking_icons = previous.icons; + craft_ui::docking::cancel_drag::(ctx, egui::Id::new("vectorcraft-panel-docking")); + true + } else { + false + } +} + +fn normalize(raw: &str) -> Option<&'static str> { + crate::menus::normalize_panel(raw) +} + +pub(crate) fn valid(layout: &Layout) -> bool { + layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str())) +} + +fn legacy(app: &VectorcraftApp) -> Layout { + let panels: Vec = crate::floating::docked_tabs(&app.ui).map(|tab| tab.info().0.to_string()).collect(); + let active = panels.iter().position(|id| id == app.ui.dock_tab.info().0).unwrap_or(0); + let root = (!panels.is_empty()).then_some(Node::Tabs { panels, active }); + let mut layout = Layout { root, floating: Vec::new() }; + for group in &app.ui.floating_panels { + let panels: Vec = group.panels.iter().filter(|id| normalize(id).is_some() && !layout.contains(id)).cloned().collect(); + if !panels.is_empty() && group.pos.iter().all(|v| v.is_finite()) { + let active = group.active.min(panels.len().saturating_sub(1)); + layout.floating.push(Floating { + panels, + active, + rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0).clamp(200.0, 720.0), 400.0], + }); + } + } + layout +} + +fn icon_origins(app: &VectorcraftApp) -> std::collections::BTreeSet { + let mut icons = app.ui.docking_icons.clone(); + if app.ui.docking.is_none() { + // Legacy workspaces have no custom groups in the dock. Icon-panel floats therefore + // retain their native rail destination when first converted to a shared layout. + icons.extend( + app.ui + .floating_panels + .iter() + .flat_map(|group| group.panels.iter()) + .filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())) + .cloned(), + ); + } + icons +} + +fn first_root(layout: &Layout, except: &str) -> Option { + let root = Layout { root: layout.root.clone(), floating: Vec::new() }; + root.panels().into_iter().find(|p| p.as_str() != except).cloned() +} + +// Preserve the native dock's selected tab when another tab leaves. If the selected tab +// itself leaves, the remaining group's first tab is the native fallback. +fn float_fallback(layout: &Layout, panel: &str) -> Option { + let mut pending: Vec<_> = layout.root.iter().collect(); + while let Some(node) = pending.pop() { + match node { + Node::Tabs { panels, active } if panels.iter().any(|id| id == panel) => { + return panels.get(*active).filter(|id| id.as_str() != panel).or_else(|| panels.iter().find(|id| id.as_str() != panel)).cloned(); + } + Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]), + _ => {} + } + } + None +} + +fn restore_float_fallback(layout: &mut Layout, panel: Option) -> Result<(), String> { + if let Some(panel) = panel.filter(|panel| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(panel)) { + layout.apply(Action::Activate { panel }).map_err(|error| error.to_string())?; + } + Ok(()) +} + +fn ensure(layout: &mut Layout, panel: &str) -> Result<(), String> { + let anchor = first_root(layout, panel); + layout.apply(Action::Open { panel: panel.into(), anchor }).map_err(|e| e.to_string()) +} + +fn group_members(layout: &Layout, panel: &str) -> Vec { + if let Some(group) = layout.floating.iter().find(|g| g.panels.iter().any(|id| id == panel)) { + return group.panels.clone(); + } + let mut visit: Vec<_> = layout.root.iter().collect(); + while let Some(node) = visit.pop() { + match node { + Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return panels.clone(), + Node::Split { first, second, .. } => { + visit.push(second); + visit.push(first); + } + _ => {} + } + } + vec![panel.into()] +} + +fn number(params: &Value, key: &str, default: f32) -> Result { + match params.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .filter(|v| v.is_finite() && v.abs() <= 1_000_000.0) + .map(|v| v as f32) + .ok_or_else(|| format!("{key} must be a finite coordinate")), + } +} + +fn placement(params: &Value) -> Result, String> { + if let Some(before) = params.get("before") { + let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; + return Ok(Placement::Tab { before: Some(id.into()) }); + } + let zone = match params.get("zone") { + None => "tab", + Some(value) => value.as_str().ok_or("zone must be a string")?, + }; + Ok(match zone { + "tab" => Placement::Tab { before: None }, + "center" => Placement::Split(Zone::Center), + "left" => Placement::Split(Zone::Left), + "right" => Placement::Split(Zone::Right), + "top" => Placement::Split(Zone::Top), + "bottom" => Placement::Split(Zone::Bottom), + _ => return Err("zone must be tab, center, left, right, top or bottom".into()), + }) +} + +pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option> { + if matches!(id, "window.panel.float" | "window.panel.dock") + && (params.get("above").is_some() + || params.get("below").is_some() + || params.get("column").is_some() + || params.get("collapsed").is_some() + || params + .get("onto") + .and_then(Value::as_str) + .and_then(normalize) + .and_then(|id| crate::floating::group_of(&app.ui, id)) + .and_then(|i| app.ui.floating_panels.get(i)) + .is_some_and(decorated) + || params + .get("panel") + .and_then(Value::as_str) + .and_then(normalize) + .and_then(|id| crate::floating::group_of(&app.ui, id)) + .and_then(|i| app.ui.floating_panels.get(i)) + .is_some_and(decorated)) + { + return Some(native_change(app, id, params)); + } + if id == "window.panel.layout" { + return Some( + params + .get("action") + .ok_or_else(|| "action is required".to_string()) + .and_then(|value| serde_json::from_value::>(value.clone()).map_err(|error| error.to_string())) + .and_then(|action| apply_action(app, action)), + ); + } + if params.get("panel").and_then(Value::as_str).is_some_and(|s| s.eq_ignore_ascii_case("tools") || s.eq_ignore_ascii_case("toolbar")) { + return None; + } + let operation = match id { + "window.panel.move" => "move", + "window.panel.float" => "float", + "window.panel.dock" => "dock", + "window.panel.activate" => "activate", + "window.panel.close" => "close", + "window.panel" if app.ui.docking.is_some() => "activate", + _ => return None, + }; + Some(change(app, operation, params).map(|value| { + if id == "window.panel" + && let Some(panel) = params.get("panel").and_then(Value::as_str).and_then(normalize) + && let Some(group) = app.ui.floating_panels.iter().find(|group| group.panels.iter().any(|id| id == panel)) + { + return json!({"floating":group.panels}); + } + value + })) +} + +// UI output and automation share this atomic dispatcher. The application owns the registry +// and saved return locations; craft-ui owns tree validation and structural changes. +fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result { + let registered = |id: &String| normalize(id) == Some(id.as_str()); + let known = match &action { + Action::Move { panel, anchor, placement } | Action::OpenAt { panel, anchor, placement } => { + registered(panel) + && registered(anchor) + && match placement { + Placement::Tab { before: Some(id) } => registered(id), + _ => true, + } + } + Action::Open { panel, anchor } => registered(panel) && anchor.as_ref().is_none_or(registered), + Action::Float { panel, .. } + | Action::Close { panel } + | Action::Activate { panel } + | Action::MoveFloating { panel, .. } + | Action::SetStackOpen { panel, .. } + | Action::ResizeStack { panel, .. } => registered(panel), + Action::ResizeSplit { path, .. } => path.len() <= craft_ui::docking::MAX_DEPTH, + }; + if !known { + return Err("docking action contains an unknown panel or an excessive split path".into()); + } + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let mut icons = icon_origins(app); + let explicit_dock = match &action { + Action::Move { panel, anchor, .. } | Action::OpenAt { panel, anchor, .. } + if (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(anchor) => + { + Some(panel.clone()) + } + _ => None, + }; + let returning = match &action { + Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)), + Action::Float { panel, .. } => { + layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)) + } + _ => None, + }; + let fallback = match &action { + Action::Float { panel, .. } => float_fallback(&layout, panel), + _ => None, + }; + let activated = match &action { + Action::Activate { panel } => Some(panel.clone()), + _ => None, + }; + layout.apply(action).map_err(|error| error.to_string())?; + restore_float_fallback(&mut layout, fallback)?; + if let Some((panel, location)) = returning { + app.ui.docking_hidden.insert(panel, location); + } + if let Some(panel) = explicit_dock { + icons.remove(&panel); + } + app.ui.docking_icons = icons; + app.ui.docking = Some(layout); + if let Some(panel) = activated { + for group in &mut app.ui.floating_panels { + if group.panels.contains(&panel) { + group.collapsed = false; + } + } + } + sync_legacy(app); + app.ui.open_panel = None; + Ok(json!({"layout": app.ui.docking})) +} + +fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + if params.get("x").is_some() != params.get("y").is_some() { + return Err("x and y must be provided together".into()); + } + let whole_group = match params.get("group") { + None => false, + Some(Value::Bool(value)) => *value, + Some(_) => return Err("group must be true or false".into()), + }; + let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] }; + let mut hidden: std::collections::BTreeMap<_, _> = + app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); + let mut icons = icon_origins(app); + let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel); + if icon_panel && !layout.contains(&panel.to_string()) { + icons.insert(panel.to_string()); + } + if operation == "activate" && icon_panel && !layout.contains(&panel.to_string()) { + app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(panel) { None } else { Some(panel.into()) }; + return Ok(json!({"panel": panel, "open": app.ui.open_panel})); + } + if operation == "dock" && params.get("anchor").or_else(|| params.get("onto")).is_none() { + let mut candidate = layout.clone(); + let mut open = None; + for member in &members { + if candidate.contains(member) { + candidate.apply(Action::Close { panel: member.clone() }).map_err(|error| error.to_string())?; + } + } + // Remove the complete group before restoring any saved neighbor. Otherwise a + // still-floating group member can accidentally receive the first restored tab. + for member in &members { + if icons.contains(member) { + open = Some(member.clone()); + } else if let Some(location) = hidden.get(member).filter(|location| { + location.floating.is_none() + && location.anchor.as_ref().is_none_or(|anchor| Layout { root: candidate.root.clone(), floating: Vec::new() }.contains(anchor)) + }) { + if candidate.restore(member.clone(), location).is_err() { + // A removed neighboring group uses the normal dock as its documented fallback. + ensure(&mut candidate, member)?; + } + } else { + ensure(&mut candidate, member)?; + } + } + if candidate.contains(&panel.to_string()) { + candidate.apply(Action::Activate { panel: panel.to_string() }).map_err(|error| error.to_string())?; + } else if icons.contains(panel) { + open = Some(panel.to_string()); + } + if !valid(&candidate) { + return Err("invalid restored panel group".into()); + } + app.ui.docking = Some(candidate); + app.ui.docking_icons = icons; + for member in &members { + hidden.remove(member); + } + app.ui.docking_hidden = hidden; + sync_legacy(app); + app.ui.open_panel = open; + return Ok(json!({"panel": panel, "floating": false, "layout": app.ui.docking})); + } + let mut restored = false; + if operation == "dock" + && params.get("anchor").or_else(|| params.get("onto")).is_none() + && let Some(location) = hidden.get(panel).filter(|location| { + location.floating.is_none() + && location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor)) + }) + { + let mut candidate = layout.clone(); + if candidate.contains(&panel.to_string()) { + candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + if candidate.restore(panel.into(), location).is_ok() { + layout = candidate; + restored = true; + hidden.remove(panel); + } + } + match operation { + "dock" if restored => {} + "float" => { + let old = layout + .floating + .iter() + .find(|group| group.panels.iter().any(|id| id == panel) && group.panels.len() == members.len()) + .map(|group| group.rect) + .unwrap_or([400.0 + 24.0 * layout.floating.len().min(12) as f32, 140.0 + 24.0 * layout.floating.len().min(12) as f32, 300.0, 400.0]); + let rect = + [number(params, "x", old[0])?, number(params, "y", old[1])?, number(params, "width", old[2])?, number(params, "height", old[3])?]; + let fallback = float_fallback(&layout, panel).or_else(|| first_root(&layout, panel)); + for member in &members { + if let Ok(location) = layout.location(member) + && location.floating.is_none() + { + hidden.insert(member.clone(), location); + } + } + ensure(&mut layout, panel)?; + if let Ok(location) = layout.location(&panel.to_string()) + && location.floating.is_none() + { + hidden.insert(panel.into(), location); + } + if let Some(onto) = params.get("onto") { + let onto = onto.as_str().and_then(normalize).ok_or("onto must name a floating panel")?; + if members.iter().any(|id| id == onto) + || layout.floating.iter().any(|group| group.panels.iter().any(|id| id == panel) && group.panels.iter().any(|id| id == onto)) + || !layout.floating.iter().any(|group| group.panels.iter().any(|id| id == onto)) + { + return Err("onto must name a panel floating in another group".into()); + } + for member in &members { + layout + .apply(Action::Move { panel: member.clone(), anchor: onto.into(), placement: Placement::Tab { before: None } }) + .map_err(|e| e.to_string())?; + } + } else { + let lead = members.first().ok_or("panel group is empty")?; + layout.apply(Action::Float { panel: lead.clone(), rect }).map_err(|e| e.to_string())?; + for member in members.iter().filter(|id| *id != lead) { + layout + .apply(Action::Move { panel: member.clone(), anchor: lead.clone(), placement: Placement::Tab { before: None } }) + .map_err(|e| e.to_string())?; + } + } + layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?; + restore_float_fallback(&mut layout, fallback)?; + } + "move" | "dock" => { + if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { + return Err("move requires a destination panel".into()); + } + let anchor = match params.get("anchor").or_else(|| params.get("onto")) { + Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), + None => first_root(&layout, panel), + }; + let placement = placement(params)?; + if let Some(anchor) = anchor { + ensure(&mut layout, panel)?; + layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?; + } else if operation == "dock" { + if layout.contains(&panel.to_string()) { + layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?; + } else { + return Err("move requires an existing destination panel".into()); + } + } + "close" => { + hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); + layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + _ => { + if !layout.contains(&panel.to_string()) + && let Some(location) = hidden.get(panel) + { + // A missing neighbor is allowed: the normal visible dock becomes the fallback. + let _ = layout.restore(panel.into(), location); + } + ensure(&mut layout, panel)?; + hidden.remove(panel); + } + } + if operation == "dock" && whole_group { + for member in members.iter().filter(|id| id.as_str() != panel) { + layout + .apply(Action::Move { panel: member.clone(), anchor: panel.into(), placement: Placement::Tab { before: None } }) + .map_err(|e| e.to_string())?; + } + layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?; + } + let group = layout.floating.iter().find(|group| group.panels.iter().any(|id| id == panel)); + let result = json!({"panel": panel, "floating": group.is_some(), "group": group.map(|g| &g.panels), "pos": group.map(|g| [g.rect[0], g.rect[1]]), "layout": layout}); + if matches!(operation, "move" | "dock") && (Layout { root: layout.root.clone(), floating: Vec::new() }).contains(&panel.to_string()) { + icons.remove(panel); + } + app.ui.docking = Some(layout); + app.ui.docking_hidden = hidden; + app.ui.docking_icons = icons; + if operation == "activate" { + for group in &mut app.ui.floating_panels { + if group.panels.iter().any(|id| id == panel) { + group.collapsed = false; + } + } + } + sync_legacy(app); + app.ui.open_panel = None; + app.ui.dock = true; + if matches!(operation, "activate" | "move" | "dock") { + app.ui.dock_collapsed = false; + } + Ok(result) +} + +struct PanelContent<'a> { + app: &'a mut VectorcraftApp, + commands: Vec<(&'static str, Value)>, +} + +impl craft_ui::docking::DockContent for PanelContent<'_> { + fn body(&mut self, ui: &mut egui::Ui, panel: &String) { + let width = ui.available_width(); + let height = (ui.available_height() - 20.0).max(0.0); + if crate::state::DockTab::from_id(panel).is_some() { + crate::dock::panel_body(self.app, ui, panel, width, height); + } else { + egui::ScrollArea::vertical() + .id_salt(("shared-panel-body", panel)) + .auto_shrink([false, false]) + .show(ui, |ui| crate::dock::panel_body(self.app, ui, panel, width, height)); + } + } + + fn panel_menu(&mut self, ui: &mut egui::Ui, panel: &String) { + crate::panels::panel_menu_items(self.app, ui, panel); + } + + fn group_header_width(&self, _panels: &[String], _active: &String, floating: bool) -> f32 { + if floating { 48.0 } else { 80.0 } + } + + fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[String], active: &String, floating: bool) { + let rect = ui.max_rect(); + let menu = egui::Rect::from_center_size(rect.right_center() - egui::vec2(12.0, 0.0), egui::vec2(16.0, 16.0)); + crate::panels::panel_menu(self.app, ui, active, menu); + let grip = egui::Rect::from_min_max(rect.min, egui::pos2(menu.left() - 4.0, rect.bottom())); + if floating { + let response = ui.put(grip, egui::Button::new("×")).on_hover_text(tl!("Dock Panel")); + if response.clicked() { + self.commands.push(("window.panel.dock", json!({"panel": active, "group": true}))); + } + } else { + let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::drag()); + if response.drag_started() + && let Some(pos) = response.interact_pointer_pos() + { + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("vectorcraft-root-group-drag"), + RootGroupDrag { + panel: active.clone(), + layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)), + hidden: self.app.ui.docking_hidden.clone(), + generation: self.app.ui.docking_generation, + }, + ) + }); + self.commands.push(("window.panel.float", json!({"panel": active, "group": true, "x": pos.x, "y": pos.y}))); + } + response.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); + } + } +} + +pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { + let root_key = egui::Id::new("vectorcraft-root-group-drag"); + if let Some(drag) = ui.ctx().data(|data| data.get_temp::(root_key)) { + if drag.generation != app.ui.docking_generation { + ui.ctx().data_mut(|data| data.remove::(root_key)); + } else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) { + app.ui.docking = Some(drag.layout); + app.ui.docking_hidden = drag.hidden; + sync_legacy(app); + ui.ctx().data_mut(|data| data.remove::(root_key)); + } else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) { + if ui.input(|input| input.pointer.primary_down()) { + if let Err(error) = app.run("window.panel.float", json!({"panel":drag.panel, "group":true, "x":pos.x, "y":pos.y})) { + app.ui.status = error; + } + if let Some(panel) = normalize(&drag.panel) + && let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + { + crate::floating::preview(ui.ctx(), target, rect); + } + } else { + if let Some(panel) = normalize(&drag.panel) + && let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + { + let (id, params) = match target { + crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})), + crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})), + crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})), + crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})), + crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})), + }; + if let Err(error) = app.run(id, params) { + app.ui.status = error; + } + } + ui.ctx().data_mut(|data| data.remove::(root_key)); + } + } + } + let area = egui::Id::new("vectorcraft-panel-docking"); + 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); + previous.is_some_and(|generation| generation != app.ui.docking_generation) + }); + 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) { + app.ui.docking = None; + app.ui.status = "Invalid panel layout; restored the default dock".into(); + return false; + } + let layout = layout.clone(); + let t = crate::theme::Tokens::get(ui.ctx()); + let collapsed = app.ui.dock_collapsed; + let mut display = if collapsed { Layout { root: None, floating: layout.floating.clone() } } else { layout.clone() }; + display + .floating + .retain(|group| !app.ui.floating_panels.iter().any(|native| decorated(native) && native.panels.iter().any(|id| group.panels.contains(id)))); + let mut draw = |ui: &mut egui::Ui| { + if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) { + let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed})); + } + if collapsed { + let root = Layout { root: layout.root.clone(), floating: Vec::new() }; + for panel in root.panels() { + let info = crate::state::DockTab::from_id(panel) + .map(|tab| tab.info()) + .or_else(|| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == panel).copied()); + if let Some((_, label, icon)) = info + && crate::widgets::icon_button(ui, icon, tl!(label), app.ui.open_panel.as_ref() == Some(panel), 30.0).clicked() + { + app.ui.open_panel = if app.ui.open_panel.as_ref() == Some(panel) { None } else { Some(panel.clone()) }; + } + } + } + let mut style = craft_ui::docking::DockStyle::from_ui(ui); + style.tab_height = 33.0; + style.background = t.panel; + style.tab_background = t.tab_strip; + style.active_background = t.panel; + style.text = t.text_strong; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.float_label = tl!("Float Panel").to_string(); + style.close_label = tl!("Close Panel").to_string(); + style.move_label = tl!("Group with").to_string(); + style.panels_label = tl!("Panels").to_string(); + style.resize_label = tl!("Resize panels").to_string(); + style.resize_window_label = tl!("Resize panel window").to_string(); + let mut content = PanelContent { app, commands: Vec::new() }; + let output = craft_ui::docking::DockArea::new(egui::Id::new("vectorcraft-panel-docking")).show_customized( + ui, + &display, + &style, + |id| crate::state::all_panels().find(|p| p.0 == id).map(|p| tl!(p.1).to_string()).unwrap_or_else(|| id.clone()), + |_| craft_ui::docking::Permissions::default(), + |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, + &mut content, + ); + for (id, params) in content.commands { + if let Err(error) = app.run(id, params) { + app.ui.status = error; + } + } + output + }; + let output = if layout.root.is_some() { + let panel = egui::Panel::right("shared-panel-dock") + .default_size(310.0) + .size_range(220.0..=720.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel)); + let panel = if collapsed { panel.exact_size(38.0).resizable(false) } else { panel }; + let shown = panel.show(ui, &mut draw); + ui.ctx().data_mut(|data| data.insert_temp(crate::floating::dock_rect_id(), shown.response.rect)); + shown.inner + } else { + draw(ui) + }; + if let Some(panel) = output.group_drag.as_deref().and_then(normalize) + && let Some(pos) = ui.input(|input| input.pointer.interact_pos()) + && let Some((target, rect)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + { + crate::floating::preview(ui.ctx(), target, rect); + } + if let Some(error) = output.error { + app.ui.status = error.to_string(); + } + // Tab and icon tears finish as model actions; title moves finish as group_drop. + // Native sets are absent from the shared render tree, so their pointer target must win + // over a float proposal or a root target drawn behind the native window. + let tab_drop = ui.input(|input| input.pointer.primary_released().then(|| input.pointer.interact_pos()).flatten()).and_then(|pos| { + output.actions.iter().find_map(|action| { + let panel = match action { + Action::Float { panel, .. } | Action::Move { panel, .. } => normalize(panel)?, + _ => return None, + }; + crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + .filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock)) + .map(|(target, _)| (panel, target)) + }) + }); + if ui.input(|input| input.pointer.any_released()) { + let native_drop = output.group_drop.as_ref().is_some_and(|(panel, pos)| { + normalize(panel).and_then(|id| crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(id), *pos)).is_some() + }); + if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) + && !native_drop + && tab_drop.is_none() + && cancel_legacy_drag(app, ui.ctx()) + { + return app.ui.docking.is_some(); + } + ui.ctx().data_mut(|data| data.remove::(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"))); + } + for action in output.actions { + if tab_drop + .is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel)) + { + continue; + } + if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { + app.ui.status = error; + } + } + let group_drop = output.group_drop.and_then(|(panel, pos)| { + let panel = normalize(&panel)?; + crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true)) + }); + if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) { + let (command, params) = match target { + crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})), + crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})), + crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})), + crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})), + crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})), + }; + // Shared and native windows have already chosen their render ownership for this + // frame. Switch representations only after both renderers finish, so a panel's + // widgets never appear in two layers in the same frame. + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("vectorcraft-native-panel-drop"), (command, params))); + } + for group in &display.floating { + if let Some(first) = group.panels.first() + && let Some(rect) = ui.ctx().memory(|memory| memory.area_rect(area.with(("floating", first)))) + { + ui.ctx().data_mut(|data| data.insert_temp(crate::floating::group_rect_id(first), rect)); + } + } + icon_rail(app, ui); + crate::floating::docked_columns(app, ui); + true +} + +fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + let column = egui::Panel::right("shared-panel-icons") + .exact_size(38.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(t.panel)) + .show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for &(id, label, icon) in crate::state::ICON_PANEL_GROUPS + .iter() + .flat_map(|group| group.iter()) + .filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id)) + { + if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) { + continue; + } + let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0)); + let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag()); + let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id)); + if response.clicked() { + app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) }; + } + legacy_tab(app, ui, id, &response); + } + }); + }) + .response + .rect; + ui.ctx().data_mut(|data| { + let dock = if app.ui.docking.as_ref().is_some_and(|layout| layout.root.is_some()) { + data.get_temp::(crate::floating::dock_rect_id()).unwrap_or(column) + } else { + column + }; + data.insert_temp(crate::floating::dock_rect_id(), dock.union(column)); + data.insert_temp(crate::floating::icons_rect_id(), column); + data.insert_temp(egui::Id::new("dock-icon-column-left"), column.left()); + }); +} + +pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { + let Some(panel) = normalize(panel) else { return }; + response.context_menu(|ui| { + if ui.button(tl!("Float Panel")).clicked() { + if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { + app.ui.status = error; + } + ui.close(); + } + ui.menu_button(tl!("Group with"), |ui| { + for (anchor, label) in crate::state::all_panels() { + if anchor != panel && ui.button(tl!(label)).clicked() { + // A closed icon panel is made visible before becoming the destination. + let result = app + .run("window.panel.activate", json!({"panel": anchor})) + .and_then(|_| app.run("window.panel.move", json!({"panel":panel,"anchor":anchor}))); + if let Err(error) = result { + app.ui.status = error; + } + ui.close(); + } + } + }); + }); + if response.drag_started() + && let Some(id) = normalize(panel) + { + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let bounds = ui.max_rect(); + let source = egui::Rect::from_min_size( + egui::pos2(bounds.left(), response.rect.top()), + egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), + ); + if ensure(&mut layout, id).is_ok() + && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("vectorcraft-panel-docking"), id.to_string(), source) + { + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"), + LegacyDrag { + layout: app.ui.docking.clone(), + open_panel: app.ui.open_panel.clone(), + floating: app.ui.floating_panels.clone(), + icons: app.ui.docking_icons.clone(), + }, + ) + }); + if !app.ui.docking.as_ref().unwrap_or(&legacy(app)).contains(&id.to_string()) + && crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id) + { + app.ui.docking_icons.insert(id.into()); + } + app.ui.docking = Some(layout); + app.ui.open_panel = None; + } + } +} + +// Keep the legacy inspection schema useful; shared layout remains the rendering authority. +pub(crate) fn decorated(group: &crate::state::FloatingPanels) -> bool { + group.column.is_some() || group.docked.is_some() || group.collapsed +} + +/// Keep the native set metadata alongside the validated shared ownership tree. Return locations +/// and root splits survive the mirror update; failed reconciliation restores the whole UI state. +pub(crate) fn reconcile_native(app: &mut VectorcraftApp, before: crate::state::UiState) -> Result<(), String> { + if before.docking.is_none() { + return Ok(()); + } + let desired = app.ui.floating_panels.clone(); + let result = (|| { + let prior = before.docking.as_ref().ok_or("missing panel layout")?; + app.ui.docking = Some(prior.clone()); + for group in &prior.floating { + for panel in &group.panels { + if !desired.iter().any(|g| g.panels.contains(panel)) { + change(app, "dock", &json!({"panel":panel}))?; + } + } + } + let mut layout = app.ui.docking.clone().ok_or("missing panel layout")?; + for group in &desired { + for panel in &group.panels { + if (Layout { root: layout.root.clone(), floating: vec![] }).contains(panel) { + if let Ok(location) = layout.location(panel) { + app.ui.docking_hidden.insert(panel.clone(), location); + } + layout.apply(Action::Close { panel: panel.clone() }).map_err(|e| e.to_string())?; + } + } + } + layout.floating = desired + .iter() + .map(|group| { + let height = prior.floating.iter().find(|g| g.panels.iter().any(|id| group.panels.contains(id))).map_or(400.0, |g| g.rect[3]); + Floating { + panels: group.panels.clone(), + active: group.active, + rect: [group.pos[0], group.pos[1], group.width.unwrap_or(300.0), height], + } + }) + .collect(); + if !valid(&layout) { + return Err("invalid native panel set".into()); + } + app.ui.docking = Some(layout); + app.ui.floating_panels = desired; + Ok(()) + })(); + if result.is_err() { + app.ui = before; + } + result +} + +fn native_change(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result { + let before = app.ui.clone(); + let result = (|| { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + if crate::floating::group_of(&app.ui, panel).is_none() { + let mut initial = params.clone(); + if let Some(object) = initial.as_object_mut() { + for key in ["above", "below", "column", "collapsed", "onto"] { + object.remove(key); + } + } + change(app, "float", &initial)?; + } + let baseline = app.ui.clone(); + let value = crate::floating::command(app, id, params).ok_or("unsupported native docking operation")??; + reconcile_native(app, baseline)?; + Ok(value) + })(); + if result.is_err() { + app.ui = before; + } + result +} + +fn sync_legacy(app: &mut VectorcraftApp) { + if let Some(layout) = &app.ui.docking { + let old = app.ui.floating_panels.clone(); + let mut pending: Vec<_> = layout.root.iter().collect(); + while let Some(node) = pending.pop() { + match node { + Node::Tabs { panels, active } => { + if let Some(tab) = panels.get(*active).and_then(|id| crate::state::DockTab::from_id(id)) { + app.ui.dock_tab = tab; + } + } + Node::Split { first, second, .. } => pending.extend([second.as_ref(), first.as_ref()]), + _ => {} + } + } + app.ui.floating_panels = layout + .floating + .iter() + .map(|group| crate::state::FloatingPanels { + panels: group.panels.clone(), + active: group.active, + pos: [group.rect[0], group.rect[1]], + width: Some(group.rect[2]), + column: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.column), + collapsed: old + .iter() + .find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))) + .is_some_and(|g| g.collapsed), + docked: old.iter().find(|native| group.panels.first().is_some_and(|first| native.panels.contains(first))).and_then(|g| g.docked), + }) + .collect(); + } +} + +#[cfg(test)] +#[path = "panel_docking_tests.rs"] +mod tests; + +/// Commit a pointer drop after shared and native panel windows have finished rendering. +pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) { + if let Some((command, params)) = + ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop"))) + && let Err(error) = app.run(command, params) + { + app.ui.status = error; + } +} diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs new file mode 100644 index 00000000..911f5d3d --- /dev/null +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -0,0 +1,576 @@ +use super::*; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn app() -> VectorcraftApp { + VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()) +} + +#[test] +fn panel_commands_group_split_reorder_float_and_restore_without_duplicate_ownership() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.move", json!({"panel":"properties", "anchor":"swatches", "before":"swatches"})).unwrap(); + let before_float = app.ui.docking.clone().unwrap(); + app.run("window.panel.float", json!({"panel":"properties", "x":50,"y":70,"width":330,"height":410})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels == ["properties"])); + app.run("window.panel.dock", json!({"panel":"properties"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); + app.run("window.panel.close", json!({"panel":"properties"})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"properties".into())); + app.run("window.panel.activate", json!({"panel":"properties"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap(), &before_float); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(valid(layout)); + let unique: std::collections::HashSet<_> = layout.panels().into_iter().collect(); + assert_eq!(unique.len(), layout.panels().len()); + let saved = serde_json::to_value(&app.ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(restored.docking, app.ui.docking); + assert_eq!(restored.docking_hidden, app.ui.docking_hidden); +} + +#[test] +fn invalid_panel_requests_are_atomic_and_do_not_enter_a_custom_workspace() { + let mut app = app(); + for (id, params) in [ + ("window.panel.move", json!({"panel":"layers","anchor":"unknown"})), + ("window.panel.move", json!({"panel":"layers","anchor":"properties","before":"unknown"})), + ("window.panel.move", json!({"panel":"layers","anchor":"properties","zone":42})), + ("window.panel.float", json!({"panel":"layers","width":-10})), + ("window.panel.float", json!({"panel":"unknown"})), + ("window.panel.float", json!({"panel":"layers","x":"invalid"})), + ] { + let old = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + assert!(app.run(id, params).is_err(), "{id}"); + assert_eq!(app.ui.docking, old); + assert_eq!(app.ui.docking_hidden, hidden); + } +} + +fn harness(app: VectorcraftApp, size: egui::Vec2) -> Harness<'static, VectorcraftApp> { + let mut ready = false; + let mut harness = Harness::builder().with_size(size).with_step_dt(1.0 / 60.0).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + egui_extras::install_image_loaders(ui.ctx()); + crate::theme::apply(ui.ctx(), crate::theme::Brightness::MediumDark); + ready = true; + return; + } + crate::floating::track(app, ui.ctx()); + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |_| {}); + crate::floating::show(app, ui.ctx()); + }, + app, + ); + harness.run_steps(4); + harness +} + +fn drag(h: &mut Harness<'static, VectorcraftApp>, from: Pos2, to: Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=8 { + h.event(Event::PointerMoved(from + (to - from) * (step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab_group() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + drag(&mut h, source, egui::pos2(160.0, 140.0)); + let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); + assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); + let source = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let destination = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + drag(&mut h, source, destination); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into()))); + assert!(valid(layout)); +} + +#[test] +fn saved_workspace_restores_the_custom_tree_and_hidden_panel_placement() { + let mut app = app(); + + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.panel.float", json!({"panel":"properties","x":50,"y":80})).unwrap(); + app.run("window.panel.close", json!({"panel":"swatches"})).unwrap(); + let layout = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + app.run("window.workspace.new", json!({"name":"Custom docking"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + assert!(app.ui.docking.is_none()); + app.run("window.workspace", json!({"name":"Custom docking"})).unwrap(); + assert_eq!(app.ui.docking, layout); + assert_eq!(app.ui.docking_hidden, hidden); +} + +#[test] +fn legacy_float_group_aliases_use_atomic_shared_groups_and_preserve_tools() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers","group":true,"x":30,"y":50})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 3); + app.run("window.panel.float", json!({"panel":"swatches","onto":"layers"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap().floating[0].panels.len(), 4); + let before = app.ui.docking.clone(); + assert!(app.run("window.panel.float", json!({"panel":"properties","onto":"missing","group":true})).is_err()); + assert_eq!(app.ui.docking, before); + app.run("window.panel.float", json!({"panel":"tools","x":70,"y":90})).unwrap(); + assert_eq!(app.ui.toolbar_pos, Some([70.0, 90.0])); + assert_eq!(app.ui.docking, before); + app.run("window.panel.dock", json!({"panel":"layers","group":true})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().floating.is_empty()); +} + +/// Explicit offscreen evidence; this creates no native windows. +#[test] +fn capture_custom_panel_docking_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for theme in crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.float", json!({"panel":"properties","x":30,"y":90,"width":300,"height":440})).unwrap(); + let mut ready = false; + let mut harness = Harness::builder().with_size(vec2(width, 800.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !ready { + egui_extras::install_image_loaders(ui.ctx()); + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx(), theme); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Custom panel workspace"); + }); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(5); + assert!(valid(harness.state().ui.docking.as_ref().unwrap())); + harness.render().unwrap().save(directory.join(format!("vectorcraft-shared-docking-{}-{width}-{scale}x.png", theme.id()))).unwrap(); + } + } + } +} + +#[test] +fn escape_cancels_the_first_drag_without_replacing_the_legacy_workspace() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + assert!(h.state().ui.docking.is_some()); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().ui.docking.is_none()); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().ui.docking.is_none()); +} + +#[test] +fn serialized_layout_actions_cover_geometry_reorder_accordion_and_atomic_rejection() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.panel.layout", json!({"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}})).unwrap(); + assert!( + matches!(app.ui.docking.as_ref().unwrap().root, Some(Node::Split { size: craft_ui::layout::SplitSize::Ratio(value), .. }) if (value - 0.35).abs() < 0.001) + ); + app.run("window.panel.float", json!({"panel":"properties"})).unwrap(); + app.run("window.panel.layout", json!({"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}})).unwrap(); + assert_eq!( + app.ui.docking.as_ref().unwrap().floating.iter().find(|group| group.panels.iter().any(|p| p == "properties")).unwrap().rect, + [44.0, 66.0, 360.0, 450.0] + ); + app.run("window.panel.layout", json!({"action":{"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"swatches"}}}}})) + .unwrap(); + assert_eq!(group_members_for_test(app.ui.docking.as_ref().unwrap(), "swatches"), ["properties", "swatches"]); + let before = app.ui.docking.clone(); + let hidden = app.ui.docking_hidden.clone(); + for action in [ + json!({"MoveFloating":{"panel":"properties","rect":[0,0,-5,30]}}), + json!({"ResizeSplit":{"path":[true,true,true],"size":{"Ratio":0.2}}}), + json!({"Open":{"panel":"unknown","anchor":null}}), + json!({"Move":{"panel":"properties","anchor":"swatches","placement":{"Tab":{"before":"unknown"}}}}), + ] { + assert!(app.run("window.panel.layout", json!({"action":action})).is_err()); + assert_eq!(app.ui.docking, before); + assert_eq!(app.ui.docking_hidden, hidden); + } + app.ui.docking = Some(Layout { + root: Some(Node::Stack { entries: vec![craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: Some(100.0) }] }), + floating: Vec::new(), + }); + app.run("window.panel.layout", json!({"action":{"SetStackOpen":{"panel":"layers","open":false}}})).unwrap(); + app.run("window.panel.layout", json!({"action":{"ResizeStack":{"panel":"layers","height":150}}})).unwrap(); + let Some(Node::Stack { entries }) = &app.ui.docking.as_ref().unwrap().root else { panic!("expected stack") }; + assert!(!entries[0].open); + assert_eq!(entries[0].height, Some(150.0)); +} + +fn group_members_for_test(layout: &Layout, 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() +} + +#[test] +fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { + let mut app = app(); + app.run("file.new", json!({"width":480,"height":360})).unwrap(); + let id = app.run("shape.rectangle", json!({"x":80,"y":70,"width":250,"height":160})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids":[id]})).unwrap(); + app.run("paint.setFill", json!({"color":"#5297bb"})).unwrap(); + app.run("stroke.set", json!({"weight":8})).unwrap(); + app.run("window.panel.float", json!({"panel":"stroke","x":40,"y":140,"width":310,"height":490})).unwrap(); + let directory = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from); + let builder = Harness::builder().with_size(vec2(1280.0, 800.0)); + let builder = if directory.is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( + |ui, app: &mut VectorcraftApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(5); + let mut pending: Vec<_> = h.output().shapes.iter().map(|shape| &shape.shape).collect(); + let mut checkbox = None; + while let Some(shape) = pending.pop() { + match shape { + egui::Shape::Text(text) if text.galley.text() == "Dashed Line" => { + checkbox = Some(text.visual_bounding_rect().center()); + break; + } + egui::Shape::Vec(shapes) => pending.extend(shapes), + _ => {} + } + } + let at = checkbox.expect("painted Dashed Line checkbox"); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + let dash = crate::panels::current_stroke(h.state()).unwrap().dash.unwrap(); + assert_eq!(dash.pattern, [12.0, 12.0]); + if let Some(directory) = &directory { + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-floating.png")).unwrap(); + } + let from = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); + let to = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + drag(&mut h, from, to); + assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); + assert_eq!(crate::panels::current_stroke(h.state()).unwrap().dash.unwrap(), dash); + if let Some(directory) = &directory { + h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-redocked.png")).unwrap(); + } +} + +#[test] +fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let destination = app.ui.docking.clone(); + app.run("window.workspace.new", json!({"name":"Destination"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let source = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + h.state_mut().run("window.workspace", json!({"name":"Destination"})).unwrap(); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().ui.docking, destination); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.docking, destination); +} + +#[test] +fn floating_icon_groups_redock_to_the_rail_and_keep_saved_origins() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"color"})).unwrap(); + app.run("window.panel.float", json!({"panel":"swatches", "onto":"color"})).unwrap(); + assert_eq!(app.ui.docking_icons.len(), 2); + let workspace = crate::workspaces::capture(&app.ui, "Icon origins"); + let saved = serde_json::to_vec(&workspace).unwrap(); + let restored: crate::workspaces::Workspace = serde_json::from_slice(&saved).unwrap(); + crate::workspaces::apply(&mut app.ui, &restored); + app.run("window.panel.dock", json!({"panel":"color", "group":true})).unwrap(); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(!layout.contains(&"color".into())); + assert!(!layout.contains(&"swatches".into())); + assert!(app.ui.floating_panels.is_empty()); + assert_eq!(app.ui.open_panel.as_deref(), Some("color"), "the requested icon pops out after redocking"); + app.run("window.panel", json!({"panel":"color"})).unwrap(); + assert!(app.ui.open_panel.is_none(), "the same icon puts it away"); + app.run("window.panel", json!({"panel":"color"})).unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some("color")); +} + +#[test] +fn all_dock_group_origins_survive_float_and_return() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers", "group":true})).unwrap(); + assert_eq!(app.ui.floating_panels[0].panels.len(), 3); + assert!(app.ui.docking_hidden.contains_key("properties")); + assert!(app.ui.docking_hidden.contains_key("layers")); + assert!(app.ui.docking_hidden.contains_key("libraries")); + app.run("window.panel.dock", json!({"panel":"layers", "group":true})).unwrap(); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(layout.floating.is_empty()); + assert_eq!(layout.panels().into_iter().map(String::as_str).collect::>(), ["properties", "layers", "libraries"]); + assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers); + assert!(valid(layout)); +} + +#[test] +fn panel_specific_menu_executes_after_first_float_regroup_and_reload() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("window.panel.float", json!({"panel":"swatches","x":40,"y":100})).unwrap(); + let mut h = harness(app, vec2(1280.0, 900.0)); + for stage in 0..3 { + if stage == 1 { + h.state_mut().run("window.panel.float", json!({"panel":"color","x":60,"y":120})).unwrap(); + h.state_mut().run("window.panel.float", json!({"panel":"swatches","onto":"color"})).unwrap(); + } else if stage == 2 { + let saved = serde_json::to_vec(&h.state().ui).unwrap(); + h.state_mut().ui = serde_json::from_slice(&saved).unwrap(); + } + h.run_steps(4); + let at = h + .query_all_by_label("Panel menu") + .find(|node| node.rect().center().x < 800.0) + .expect("floating panel menu has an accessible button") + .rect() + .center(); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + let action = h.query_all_by_label(" New Swatch…").next().expect("app-specific menu action remains reachable").rect().center(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: action, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + assert_eq!(h.state().ui.dialog.as_ref().map(|dialog| dialog.kind.as_str()), Some("newSwatch"), "stage {stage}: actual panel action executes"); + h.state_mut().ui.dialog = None; + } +} + +#[test] +fn iconic_origin_tracks_explicit_custom_dock_and_legacy_saved_floats() { + let mut custom = app(); + custom.run("window.panel.move", json!({"panel":"color","anchor":"layers"})).unwrap(); + let before = custom.ui.docking.clone().unwrap(); + custom.run("window.panel.float", json!({"panel":"color"})).unwrap(); + custom.run("window.panel.dock", json!({"panel":"color"})).unwrap(); + assert_eq!(custom.ui.docking.as_ref(), Some(&before), "explicit custom tabs are the saved destination"); + assert!(custom.ui.open_panel.is_none(), "a docked custom tab is not an icon popout"); + + let mut legacy = app(); + crate::floating::float(&mut legacy.ui, &["color"], "color", egui::pos2(120.0, 160.0)); + let saved = serde_json::to_vec(&legacy.ui).unwrap(); + legacy.ui = serde_json::from_slice(&saved).unwrap(); + assert!(legacy.ui.docking.is_none()); + legacy.run("window.panel.dock", json!({"panel":"color"})).unwrap(); + assert!(!legacy.ui.docking.as_ref().unwrap().contains(&"color".into()), "legacy icon floats return to rail"); + assert_eq!(legacy.ui.open_panel.as_deref(), Some("color")); +} + +#[test] +fn native_sets_columns_and_collapse_preserve_shared_splits_and_saved_origins() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let original = app.ui.docking.clone().unwrap(); + app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","below":"stroke","collapsed":true})).unwrap(); + let root = app.ui.docking.as_ref().unwrap().root.clone(); + assert_eq!(root, original.root); + let column = app.ui.floating_panels.first().unwrap().column; + assert!(column.is_some()); + assert_eq!(app.ui.floating_panels.len(), 2); + assert!(app.ui.floating_panels.iter().all(|g| g.column == column)); + app.run("window.panel.dock", json!({"panel":"stroke","group":true,"column":true})).unwrap(); + assert!(app.ui.floating_panels.iter().all(|g| g.docked == Some(1))); + app.run("window.workspace.new", json!({"name":"Mixed shared native"})).unwrap(); + let saved = app.ui.floating_panels.clone(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + app.run("window.workspace", json!({"name":"Mixed shared native"})).unwrap(); + assert_eq!(app.ui.floating_panels, saved); + assert_eq!(app.ui.docking.as_ref().unwrap().root, root); + app.run("window.panel.float", json!({"panel":"stroke","group":true})).unwrap(); + assert!(app.ui.floating_panels.iter().all(|g| g.docked.is_none())); + let before = serde_json::to_value(&app.ui).unwrap(); + assert!(app.run("window.panel.float", json!({"panel":"stroke","below":"unknown"})).is_err()); + // Error reporting may change status, but ownership, metadata and root remain atomic. + let mut after = serde_json::to_value(&app.ui).unwrap(); + let mut before = before; + before.as_object_mut().unwrap().remove("status"); + after.as_object_mut().unwrap().remove("status"); + assert_eq!(before, after); + app.run("window.panel.dock", json!({"panel":"stroke","group":true})).unwrap(); + app.run("window.panel.dock", json!({"panel":"color","group":true})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap().root, original.root); + assert!(app.ui.floating_panels.is_empty()); + assert!(valid(app.ui.docking.as_ref().unwrap())); +} + +#[test] +fn native_set_close_pointer_restores_shared_split_ownership() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let root = app.ui.docking.as_ref().unwrap().root.clone(); + app.run("window.panel.float", json!({"panel":"stroke","x":70,"y":90})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 900.0)); + if let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR") { + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(directory.join("vectorcraft-native-set-shared-split.png")).unwrap(); + } + let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap(); + let at = egui::pos2(area.right() - 10.0, area.top() + 1.0 + crate::floating::TITLE / 2.0); + for pressed in [true, false] { + h.event(Event::PointerMoved(at)); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(3); + assert!(h.state().ui.floating_panels.is_empty()); + assert_eq!(h.state().ui.docking.as_ref().unwrap().root, root); + assert!(valid(h.state().ui.docking.as_ref().unwrap())); +} + +#[test] +fn a_single_native_tab_redock_retains_the_remaining_float_and_return_origin() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + let root = app.ui.docking.as_ref().unwrap().root.clone(); + app.run("window.panel.float", json!({"panel":"swatches","x":80,"y":90})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","onto":"swatches","collapsed":true})).unwrap(); + assert!( + matches!(app.ui.docking.as_ref().unwrap().root.as_ref(), Some(Node::Tabs { panels, active }) if panels.get(*active).is_some_and(|panel| panel == "properties")), + "floating Color intentionally activates the first root fallback, Properties" + ); + let mut expected = Layout { root, floating: vec![] }; + expected.apply(Action::Activate { panel: "properties".to_string() }).unwrap(); + app.run("window.panel.dock", json!({"panel":"swatches"})).unwrap(); + assert_eq!(app.ui.docking.as_ref().unwrap().root, expected.root); + assert_eq!(app.ui.floating_panels.len(), 1); + assert_eq!(app.ui.floating_panels[0].panels, ["color"]); + assert!(valid(app.ui.docking.as_ref().unwrap())); +} + +#[test] +fn explicit_shared_activation_expands_a_native_collapsed_owner() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers","collapsed":true})).unwrap(); + assert!(app.ui.floating_panels[0].collapsed); + app.run("window.panel.layout", json!({"action":Action::Activate { panel:"layers".to_string() }})).unwrap(); + assert!(!app.ui.floating_panels[0].collapsed); + assert_eq!(app.ui.floating_panels[0].panels, ["layers"]); + assert!(valid(app.ui.docking.as_ref().unwrap())); +} + +#[test] +fn escape_restores_native_set_position_and_metadata_after_several_frames() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"stroke","x":80,"y":100})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 900.0)); + let original = h.state().ui.floating_panels.clone(); + let layout = h.state().ui.docking.clone(); + let area = h.ctx.memory(|m| m.area_rect(crate::floating::area_id("stroke"))).unwrap(); + let from = area.left_top() + vec2(30.0, 1.0 + crate::floating::TITLE / 2.0); + h.event(Event::PointerMoved(from)); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=8 { + h.event(Event::PointerMoved(from + vec2(step as f32 * 12.0, step as f32 * 9.0))); + h.run_steps(1); + } + assert_ne!(h.state().ui.floating_panels, original, "actual title gesture moved the set"); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.floating_panels, original); + assert_eq!(h.state().ui.docking, layout); +} + +#[test] +fn first_legacy_icon_tear_can_join_a_native_set() { + let mut app = app(); + crate::floating::float(&mut app.ui, &["stroke"], "stroke", egui::pos2(100.0, 100.0)); + crate::floating::float(&mut app.ui, &["color"], "color", egui::pos2(100.0, 100.0)); + crate::floating::attach(&mut app.ui, &[1], "stroke", true); + assert!(app.ui.docking.is_none()); + let mut h = harness(app, vec2(1280.0, 900.0)); + let source = h.query_all_by_label("Swatches").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + let color = h.ctx.data(|data| data.get_temp::(crate::floating::group_rect_id("color"))).unwrap(); + drag(&mut h, source, egui::pos2(color.center().x, color.bottom() + 2.0)); + assert!(h.state().ui.docking.is_some(), "successful first tear retains shared ownership"); + let groups = &h.state().ui.floating_panels; + let color = groups.iter().find(|g| g.panels.iter().any(|p| p == "color")).unwrap(); + let swatches = groups.iter().find(|g| g.panels.iter().any(|p| p == "swatches")).unwrap(); + assert!(color.column.is_some()); + assert_eq!(swatches.column, color.column); + assert!(valid(h.state().ui.docking.as_ref().unwrap())); +} + +#[test] +fn native_set_drop_targets_respect_the_visible_layer_above_an_ordinary_float() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"swatches","x":100,"y":100})).unwrap(); + app.run("window.panel.float", json!({"panel":"stroke","x":100,"y":100})).unwrap(); + app.run("window.panel.float", json!({"panel":"color","below":"stroke"})).unwrap(); + let h = harness(app, vec2(1280.0, 900.0)); + let rect = h.ctx.data(|data| data.get_temp::(crate::floating::group_rect_id("stroke"))).unwrap(); + let at = rect.center(); + assert_eq!(h.ctx.layer_id_at(at).unwrap().id, crate::floating::area_id("stroke"), "native set is visibly above the ordinary shared float"); + let target = crate::floating::drop_target(h.state(), &h.ctx, crate::floating::Moving::Panel("layers"), at).unwrap().0; + assert!( + matches!(target, crate::floating::Drop::Above("stroke") | crate::floating::Drop::Below("stroke")), + "visible native group owns the drop: {target:?}" + ); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f51648f3..031f468e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -200,6 +200,7 @@ pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) { let t = Tokens::get(ui.ctx()); let resp = ui.interact(rect, ui.id().with(("panel-menu", id)), Sense::click()); icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim }); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panel menu"))); let resp = resp.on_hover_text(tl!("Panel menu")); egui::Popup::menu(&resp).show(|ui| { crate::widgets::menu_scroll(ui, |ui| { diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 7a14a14f..67decc23 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -583,8 +583,9 @@ mod tests { out.textures_delta.clear(); let mut shown = vec![]; out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown)); - assert!(shown.iter().any(|t| t == "System Title Bar"), "{shown:?}"); - assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}"); + let supported = cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))); + assert_eq!(shown.iter().any(|t| t == "System Title Bar"), supported, "{shown:?}"); + assert_eq!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), supported, "{shown:?}"); a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true)); confirm(&mut a).unwrap(); assert!(a.session.prefs.system_title_bar); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 56038265..cd0fb4cf 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -277,6 +277,17 @@ pub struct UiState { /// Panels dragged out of the dock: each group floats as its own stack of tabs. #[serde(default)] pub floating_panels: Vec, + /// Shared panel membership and placement; older preferences use the legacy dock. + #[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>, + /// Panels whose saved return destination is the iconic rail. + #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] + pub docking_icons: std::collections::BTreeSet, + /// Invalidates in-flight gestures when a workspace is replaced. + #[serde(skip)] + pub docking_generation: u64, /// The Tools panel floats with its top-left corner here (dragged out by its title bar); `None`: /// docked at the window's left edge. #[serde(default)] @@ -534,6 +545,8 @@ impl UiState { f.tools.first().is_some_and(|k| seen.insert(k.clone())) }); FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos); + self.docking = self.docking.filter(crate::panel_docking::valid); + self.docking_icons.retain(|id| ICON_PANELS.iter().any(|entry| entry.0 == id)); // Overrides that can't fire (modifier-only chords recorded by older versions, #487) give // the default back. self.shortcut_overrides.retain(|_, c| c.is_empty() || crate::shortcut_editor::normalize(c).is_some()); @@ -558,6 +571,10 @@ impl Default for UiState { slot_tool: Default::default(), floating_flyouts: vec![], floating_panels: vec![], + docking: None, + docking_hidden: Default::default(), + docking_icons: Default::default(), + docking_generation: 0, toolbar_pos: None, status_bar: true, dock: true, diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index b2bf18fe..3c652dfd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -302,18 +302,11 @@ pub fn apply(ctx: &egui::Context, b: Brightness) { v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) }; v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) }; let w = &mut v.widgets; - for (wv, fill) in [ - (&mut w.noninteractive, t.panel), - (&mut w.inactive, t.button), - (&mut w.hovered, t.hover), - (&mut w.active, t.tool_active), - (&mut w.open, t.hover), - ] { - wv.bg_fill = fill; - wv.weak_bg_fill = fill; - wv.corner_radius = t.cr(); - wv.fg_stroke = Stroke::new(1.0, t.text); - } + craft_ui::theme::WidgetPalette { noninteractive: t.panel, inactive: t.button, hovered: t.hover, active: t.tool_active, open: t.hover }.apply_to( + w, + t.cr(), + Stroke::new(1.0, t.text), + ); w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); w.inactive.bg_stroke = Stroke::NONE; w.hovered.bg_stroke = Stroke::new(1.0, t.input_border); diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 6e7fd1c9..977bbdc6 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -31,6 +31,14 @@ pub struct Workspace { pub floating_flyouts: Vec, /// Panels dragged out of the dock, where they float (built-in workspaces: none). pub floating_panels: Vec, + /// Custom docking tree, including floating tab groups. + #[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>, + /// Panels whose saved return destination is the iconic rail. + #[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")] + pub docking_icons: std::collections::BTreeSet, /// Where the Tools panel floats (built-in workspaces: docked). pub toolbar_pos: Option<[f32; 2]>, } @@ -128,6 +136,9 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { status_bar: ui.status_bar, floating_flyouts: ui.floating_flyouts.clone(), floating_panels: ui.floating_panels.clone(), + docking: ui.docking.clone(), + docking_hidden: ui.docking_hidden.clone(), + docking_icons: ui.docking_icons.clone(), toolbar_pos: ui.toolbar_pos, } } @@ -147,6 +158,11 @@ pub fn apply(ui: &mut UiState, w: &Workspace) { ui.status_bar = w.status_bar; ui.floating_flyouts = w.floating_flyouts.clone(); ui.floating_panels = w.floating_panels.clone(); + ui.docking_generation = ui.docking_generation.wrapping_add(1); + ui.docking = w.docking.clone().filter(crate::panel_docking::valid); + ui.docking_hidden = w.docking_hidden.clone(); + ui.docking_icons = + w.docking_icons.iter().filter(|id| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == id.as_str())).take(64).cloned().collect(); ui.toolbar_pos = w.toolbar_pos; FloatingPanels::sanitize(&mut ui.floating_panels, &mut ui.toolbar_pos); ui.flyout = None; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8ad7af87..98dfcb12 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -3,6 +3,11 @@ `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). One JSON request per line: `{"id": 1, "method": "ui.inspect", "params": {}}` → `{"id": 1, "ok": true, "result": {...}}` or `{"id":1,"ok":false,"error":"..."}`. +Set `VECTORCRAFT_CONFIG_DIR` to a dedicated directory for an isolated native test profile. Preferences, +logs, recovery and user libraries then use that root, without importing the legacy DrawCraft profile. +An unset override preserves the usual platform paths and legacy migration; an explicitly empty override +disables those paths. `VECTORCRAFT_NO_PREFS` continues to disable preference reads and writes. + **Only requests are read.** Every line must be a JSON object with a string `method` (`id` and `params` are optional; blank lines are skipped). Anything else gets one error reply (`{"ok": false, "error": "… closing the connection"}`) and the server **closes the connection**, so @@ -697,3 +702,34 @@ drags its points (a press on a point focuses it, and a press on one of the focus handle); a Mesh tool click inside a mesh envelope adds a row and a column. While an envelope or its content is selected the Control bar shows its controls (Edit Envelope / Edit Contents, warp style, orientation, bend and distortions or mesh rows and columns, Reset, Envelope Options); each runs an `object.envelope.*` command. + +## Shared panel docking + +Panel arrangements support tab grouping and reordering, horizontal and vertical splits, +and movable, resizable floating groups inside the application window. Drag a panel tab, +or use its context menu. Floating groups remain within the application viewport. + +The shared layout begins with the existing workspace on the first docking operation. +Workspace saves retain its groups, active tabs, split sizes, floating geometry, and the +positions of hidden panels. Resetting a built-in workspace restores its default dock. + +- `window.panel.move {panel, anchor, zone?, before?}` moves a panel into the target group. + `zone` is `tab` (default), `center`, `left`, `right`, `top`, or `bottom`; `before` reorders + tabs by panel ID. The destination must already be visible. +- `window.panel.float {panel, x?, y?, width?, height?}` detaches a panel. +- `window.panel.dock {panel, anchor?, zone?, before?}` returns a panel to its previous + dock placement when possible, or to the supplied destination. +- `window.panel.activate {panel}` reveals and selects a panel, restoring its saved + position when possible. `window.panel.close {panel}` hides it. + +Panel IDs are stable across themes. Invalid IDs, non-finite or invalid geometry, and +invalid destinations return an error without changing the arrangement. + +`window.panel.layout {action}` also accepts the shared, externally tagged action schema. +For example, `{"action":{"MoveFloating":{"panel":"properties","rect":[44,66,360,450]}}}` +updates a floating group's position and size; `{"action":{"ResizeSplit":{"path":[],"size":{"Ratio":0.35}}}}` +resizes the root divider (`false`/`true` path entries select first/second children). +`Move` with `placement: {"Tab":{"before":"swatches"}}` reorders tabs, and +`SetStackOpen {panel,open}` / `ResizeStack {panel,height}` control accordion entries. +The UI dispatches these same commands. Unknown panel IDs, invalid geometry, and stale +split paths return an error without changing the workspace. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index d1994261..5e724f03 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -92,7 +92,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool { /// External crates that constitute a UI toolkit / windowing dependency. /// Entries ending in `*` are prefixes. -pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"]; +pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy*"]; /// First layer allowed to use UI crates. pub const UI_MIN_LAYER: u8 = 6; @@ -305,11 +305,12 @@ mod tests { #[test] fn ui_crates_below_l6_flagged() { - for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { + for dep in ["egui", "eframe", "winit", "egui_kittest", "craft-ui", "rfd", "bevy_ecs", "bevy"] { let v = check(&[c("vectorcraft-engine", &[(dep, Normal, false)])]); assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); } assert!(check(&[c("vectorcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); + assert!(check(&[c("vectorcraft-ui-egui", &[("craft-ui", Normal, false)])]).is_empty()); assert!(check(&[c("vectorcraft-mcp", &[("winit", Normal, false)])]).is_empty()); }