diff --git a/Cargo.lock b/Cargo.lock index 9473c97..7d4d8b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -796,6 +796,15 @@ dependencies = [ "libm", ] +[[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" @@ -1122,6 +1131,7 @@ dependencies = [ name = "designcraft-ui-egui" version = "0.5.0" dependencies = [ + "craft-ui", "designcraft-color", "designcraft-compose", "designcraft-doc", diff --git a/Cargo.toml b/Cargo.toml index d69017d..b4531a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ quick-xml = "0.38" zip = { version = "3", default-features = false, features = ["deflate"] } # ui +# Shared frontend components; keep this dependency above the engine boundary. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" egui_extras = { version = "0.36", default-features = false, features = ["svg"] } eframe = { version = "0.36", default-features = false } diff --git a/README.md b/README.md index 5c7d4c1..2ef9899 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or ## Quick start +The egui frontend requires Rust 1.95 or newer (including its shared `craft-ui` components). + ```sh cargo run --release -p designcraft # desktop app (start screen) cargo run --release -p designcraft -- --sample # open the sample magazine diff --git a/ROADMAP.md b/ROADMAP.md index 86a4354..a8ba2db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,9 @@ # DesignCraft roadmap - FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage. +- UI.5: Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. + +- UI.4: Shared pane geometry and widget-state theme application through `craft-ui`, retaining all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. - AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it. diff --git a/apps/designcraft/src/gpu.rs b/apps/designcraft/src/gpu.rs index e80f566..6dfb023 100644 --- a/apps/designcraft/src/gpu.rs +++ b/apps/designcraft/src/gpu.rs @@ -367,7 +367,9 @@ mod tests { // out of the set once it failed. write(&path, &record(Some(Backend::Gl), &[])).expect("write"); let s = Startup::begin_unforced(Some(path.clone()), V).expect("candidates"); - if candidates().len() > 1 { + // This synthetic prior start records OpenGL even on Metal-only macOS. + // Switching depends on the chosen backend, not the candidate count. + if s.backend != Backend::Gl { assert_eq!(s.switched_from, Some(Backend::Gl)); let line = s.status_line().expect("status"); assert!(line.contains("OpenGL") && line.contains(s.backend.name()) && line.contains("WGPU_BACKEND"), "{line}"); diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index c306062..d0241fc 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -16,6 +16,7 @@ mod gpu; mod logging; #[cfg(target_os = "macos")] mod native_menu; +mod profile; use designcraft_engine::Session; use designcraft_ui_egui::{DesignApp, Services}; @@ -67,27 +68,13 @@ impl eframe::App for App { } } -/// The per-user settings directory: `ui.json`, `prefs.json`, `gpu.json` and `logs/` live here. -fn config_dir() -> Option { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/DesignCraft")) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("DesignCraft")) - } 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("designcraft")) - } -} - /// `DESIGNCRAFT_NO_PREFS`: read and write nothing in the settings directory (tests, demos). fn no_prefs() -> bool { std::env::var_os("DESIGNCRAFT_NO_PREFS").is_some() } fn prefs_path() -> Option { - config_dir().map(|b| b.join("ui.json")) + profile::config_dir().map(|b| b.join("ui.json")) } fn load_prefs(app: &mut DesignApp) { @@ -249,7 +236,7 @@ fn main() -> eframe::Result { } // The log file lives under the settings directory; opened after the arguments, so `--version` // leaves no file behind. Records logged until now are written to it first. - if let (Some(logger), Some(dir)) = (logger, config_dir()) { + if let (Some(logger), Some(dir)) = (logger, profile::config_dir()) { match logger.attach_dir(&dir.join("logs")) { Ok(path) => log::info!("DesignCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. @@ -273,7 +260,7 @@ fn main() -> eframe::Result { } // The graphics backend, decided before the window exists: a driver that faults takes the // process down before any Rust code can catch it (see `gpu`). - let gpu = gpu::Startup::begin(if no_prefs() { None } else { config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); + let gpu = gpu::Startup::begin(if no_prefs() { None } else { profile::config_dir().map(|d| d.join(gpu::FILE)) }, env!("CARGO_PKG_VERSION")); if let Some(s) = &gpu && let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { @@ -304,7 +291,7 @@ fn main() -> eframe::Result { } let mut session = Session::new(); // Crash recovery: reopen what a previous run left unsaved, then keep it current. - session.recovery_dir = designcraft_engine::recovery::default_dir(); + session.recovery_dir = profile::recovery_dir(); let recovered = session.execute("file.recovery.open", &serde_json::json!({})).ok(); let mut app = DesignApp::new(session, services()); if let Some(line) = gpu.as_ref().and_then(gpu::Startup::status_line) { diff --git a/apps/designcraft/src/profile.rs b/apps/designcraft/src/profile.rs new file mode 100644 index 0000000..c3da12b --- /dev/null +++ b/apps/designcraft/src/profile.rs @@ -0,0 +1,71 @@ +//! Native settings and recovery paths, including isolated automation profiles. + +use std::ffi::OsString; +use std::path::PathBuf; + +pub fn config_dir() -> Option { + config_dir_with(std::env::consts::OS, |key| std::env::var_os(key)) +} + +fn config_dir_with(os: &str, env: impl Fn(&str) -> Option) -> Option { + if let Some(root) = env("DESIGNCRAFT_CONFIG_DIR") { + // An explicitly empty override must not fall back to the user's profile. + return (!root.is_empty()).then(|| PathBuf::from(root)); + } + match os { + "macos" => env("HOME").map(|p| PathBuf::from(p).join("Library/Application Support/DesignCraft")), + "windows" => env("APPDATA").map(|p| PathBuf::from(p).join("DesignCraft")), + _ => { + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|p| PathBuf::from(p).join(".config"))).map(|p| p.join("designcraft")) + } + } +} + +pub fn recovery_dir() -> Option { + recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir) +} + +fn recovery_dir_with(root: Option, default: impl FnOnce() -> Option) -> Option { + match root { + Some(root) => (!root.is_empty()).then(|| PathBuf::from(root).join("Recovery")), + None => default(), + } +} + +#[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_overrides_every_platform_and_recovery() { + for os in ["macos", "windows", "linux"] { + let dir = config_dir_with(os, |key| if key == "DESIGNCRAFT_CONFIG_DIR" { Some("/isolated".into()) } else { environment(key) }); + assert_eq!(dir, Some(PathBuf::from("/isolated"))); + } + assert_eq!(recovery_dir_with(Some("/isolated".into()), || Some("/user/recovery".into())), Some(PathBuf::from("/isolated/Recovery"))); + } + + #[test] + fn empty_override_never_reads_or_writes_the_default_profile() { + assert_eq!(config_dir_with("macos", |key| { if key == "DESIGNCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { environment(key) } }), None); + assert_eq!(recovery_dir_with(Some(OsString::new()), || Some("/user/recovery".into())), None); + } + + #[test] + fn unset_override_preserves_existing_platform_paths() { + assert_eq!(config_dir_with("macos", environment), Some("/user/Library/Application Support/DesignCraft".into())); + assert_eq!(config_dir_with("windows", environment), Some("/roaming/DesignCraft".into())); + assert_eq!(config_dir_with("linux", environment), Some("/xdg-config/designcraft".into())); + assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into())); + assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into())); + } +} diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 028971e..9929c1c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -834,7 +834,7 @@ fn distribute_rows(s: &mut Session, p: &Value) -> Result { } for key in ["row", "story", "table"] { if let Some(v) = p.get(key) - && !v.as_u64().is_some_and(|n| usize::try_from(n).is_ok()) + && v.as_u64().is_none_or(|n| usize::try_from(n).is_err()) { return Err(bad(ID, format!("{key} must be a non-negative integer"))); } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 52cd054..35a3ef0 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "DesignCraft egui frontend (InDesign-style UI); swappable — noth workspace = true [dependencies] +craft-ui = { workspace = true } designcraft-geom = { workspace = true } designcraft-color = { workspace = true } designcraft-doc = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34dd614..4c7313e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1443,8 +1443,8 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if resp.clicked() || resp.drag_started() { resp.request_focus(); } - // Keyboard for the active tool. - if ui.ctx().text_edit_focused() && !resp.has_focus() { + // Both split panes show the same document, but only the focused pane dispatches its keys. + if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) { return; } let evs = ui.input(|i| i.events.clone()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 33e7c1b..f0d34a7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -278,7 +278,7 @@ pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> app.ui.control_bar = v; } if p.get("closePanel").is_some() { - app.ui.open_panel = None; + r = crate::panel_docking::close_visible(app).map(|_| Value::Null); } if let Some(v) = p.get("dockExpanded").and_then(Value::as_bool) { app.ui.dock_expanded = v; diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index fdf20e6..72810e1 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -49,6 +49,9 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ]; pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { + if crate::panel_docking::show(app, ui) { + return; + } let t = Tokens::get(ui.ctx()); // Expanded panel stack (outermost, 253 pt) — declared first so it sits at the far right. if app.ui.dock_expanded { @@ -66,7 +69,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.ctx(), crate::i18n::tr(&app.ui.language, label), semibold(11.5), - if active { Color32::from_rgb(0xf3, 0xf3, 0xf3) } else { t.text_dim }, + if active { t.text_strong } else { t.text_dim }, ); let w = g.size().x + 26.0; let r = egui::Rect::from_min_size(egui::pos2(x, strip.min.y), vec2(w, 27.0)); @@ -75,9 +78,15 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } ui.painter().galley(egui::pos2(r.min.x + 13.0, r.center().y - g.size().y / 2.0), g, Color32::WHITE); ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border)); - if ui.interact(r, ui.id().with(("docktab", *id)), Sense::click()).clicked() { + let response = craft_ui::tabs::Tab::new(ui.id().with(("docktab", *id)), label, active).sense(Sense::click_and_drag()).show_at( + ui, + r, + |_, _| {}, + ); + if response.clicked() { app.ui.dock_tab = id.to_string(); } + crate::panel_docking::legacy_tab(app, ui, id, &response); x += w; } let menu_r = egui::Rect::from_min_size(egui::pos2(strip.max.x - 24.0, strip.min.y + 4.0), vec2(18.0, 18.0)); @@ -100,13 +109,16 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { for (id, label, icon) in ICON_PANELS { let open = app.ui.open_panel.as_deref() == Some(*id); let floats = app.ui.floating.iter().any(|(p, _)| p == id); - if icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).clicked() { + let response = + icons::button(ui, icon, 28.0, open || floats, crate::i18n::tr(&app.ui.language, label)).interact(Sense::click_and_drag()); + if response.clicked() { if floats { ui.ctx().move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating_panel", *id)))); } else { app.ui.open_panel = if open { None } else { Some(id.to_string()) }; } } + crate::panel_docking::legacy_tab(app, ui, id, &response); ui.add_space(1.0); } }); @@ -123,6 +135,9 @@ fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) { /// A panel opened from the icon column, shown as a floating flyout next to the dock. pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { + if app.ui.docking.is_some() { + return; + } let Some(id) = app.ui.open_panel.clone() else { return }; let t = Tokens::get(ctx); let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); @@ -170,6 +185,9 @@ pub fn flyout(app: &mut DesignApp, ctx: &egui::Context) { /// The contents of an icon-column panel. pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { match id { + "properties" => panels::properties::show(app, ui), + "pages" => panels::pages::show(app, ui), + "layers" => panels::layers::show(app, ui), "swatches" => panels::swatches::show(app, ui), "paragraphStyles" => panels::styles::paragraph(app, ui), "characterStyles" => panels::styles::character(app, ui), @@ -209,20 +227,27 @@ pub fn panel_body(app: &mut DesignApp, ui: &mut egui::Ui, id: &str) { /// Tear panel `id` off the dock into a floating window at `at`. pub fn float_panel(app: &mut DesignApp, id: &str, at: egui::Pos2) { - if app.ui.open_panel.as_deref() == Some(id) { - app.ui.open_panel = None; + if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.float", &serde_json::json!({"panel":id,"x":at.x,"y":at.y})) { + app.status(error); } - app.ui.floating.retain(|(p, _)| p != id); - app.ui.floating.push((id.to_string(), [at.x, at.y])); } /// Put a floating panel back in the dock's icon column. pub fn dock_panel(app: &mut DesignApp, id: &str) { + if app.ui.docking.is_some() { + if let Some(Err(error)) = crate::panel_docking::command(app, "window.panel.dock", &serde_json::json!({"panel":id})) { + app.status(error); + } + return; + } app.ui.floating.retain(|(p, _)| p != id); } /// Floating panels: movable, resizable windows; "Dock" returns one to the icon column. pub fn floating(app: &mut DesignApp, ctx: &egui::Context) { + if app.ui.docking.is_some() { + return; + } let t = Tokens::get(ctx); for (id, at) in app.ui.floating.clone() { let label = crate::i18n::tr(&app.ui.language, ICON_PANELS.iter().find(|p| p.0 == id).map(|p| p.1).unwrap_or("Panel")).to_owned(); @@ -291,3 +316,96 @@ fn dock_glyph(p: &egui::Painter, r: egui::Rect, c: Color32) { p.rect_stroke(b, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Middle); p.rect_filled(egui::Rect::from_min_max(egui::pos2(b.max.x - 3.5, b.min.y), b.max), 0.0, c); } + +#[cfg(test)] +mod visual_regressions { + use super::*; + + 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 + } + + /// Opt-in evidence for native screenshot review; ordinary test runs stay headless and fast. + #[test] + fn capture_dock_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 crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui.dock_expanded = true; + app.ui.dock_tab = "properties".into(); + let mut ready = false; + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(width, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), "en"); + crate::theme::apply(ui.ctx(), &Tokens::for_brightness(brightness)); + ready = true; + return; + } + show(app, ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("designcraft-dock-{}-{width}-{scale}x", brightness.id()); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("dock")).unwrap().outer_rect; + let region = serde_json::json!({ + "screen": [width, 640.0], "scale": scale, + "regions": {"dock-tabs": [panel.left(), panel.top(), panel.width(), 45.0]} + }); + std::fs::write(directory.join(format!("{stem}.json")), serde_json::to_vec_pretty(®ion).unwrap()).unwrap(); + } + } + } + } + + fn luminance(c: Color32) -> f32 { + let linear = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(c.r()) + 0.7152 * linear(c.g()) + 0.0722 * linear(c.b()) + } + + #[test] + fn selected_dock_tab_has_readable_rendered_contrast_in_every_theme() { + for brightness in crate::theme::Brightness::ALL { + for width in [800.0, 1280.0] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + let tokens = Tokens::for_brightness(brightness); + crate::theme::apply(&ctx, &tokens); + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.ui.dock_expanded = true; + app.ui.dock_tab = "properties".into(); + let mut output = ctx.run_ui(scaled_input(vec2(width, 640.0), scale), |ui| show(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(width, 640.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.job.text == "Properties" => Some(text), + _ => None, + }) + .expect("the active Properties tab is painted"); + let color = text.galley.job.sections.first().expect("tab text has a format").format.color; + let a = luminance(color); + let b = luminance(tokens.panel); + let contrast = (a.max(b) + 0.05) / (a.min(b) + 0.05); + assert!(contrast >= 4.5, "{brightness:?} at {width}: selected tab contrast is {contrast:.2}"); + } + } + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fb81e9c..5e1046d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dock; pub mod i18n; pub mod icons; pub mod menus; +mod panel_docking; pub mod panels; pub mod render_worker; mod rtl; @@ -97,6 +98,11 @@ pub struct SavedWorkspace { pub dock_expanded: bool, pub open_panel: Option, pub floating: Vec<(String, [f32; 2])>, + /// Custom panel membership and placement; absent in older workspace files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub docking: Option>, + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub docking_hidden: std::collections::BTreeMap>, } /// A missing on-by-default bool stays on. `bool::default` is false. @@ -217,6 +223,20 @@ pub struct UiState { pub open_panel: Option, /// Panels torn off the dock into their own floating windows: (panel id, top-left position). pub floating: Vec<(String, [f32; 2])>, + /// Shared panel layout after the user customizes 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>, + /// Invalidates in-flight gestures when a workspace is replaced. + #[serde(skip)] + pub docking_generation: u64, + /// One-frame reveal request; z-order is never persisted. + #[serde(skip)] + pub docking_raise: Option, + /// Last explicitly revealed customized panel, for the legacy closePanel control. + #[serde(skip)] + pub docking_opened: Option, pub dock_expanded: bool, pub units: Unit, pub workspace: String, @@ -310,6 +330,11 @@ impl Default for UiState { dock_tab: "properties".into(), open_panel: None, floating: Vec::new(), + docking: None, + docking_hidden: Default::default(), + docking_generation: 0, + docking_raise: None, + docking_opened: None, dock_expanded: true, units: Unit::Picas, workspace: "Essentials".into(), @@ -824,6 +849,9 @@ impl DesignApp { let t0 = now_ms(); let t = theme::Tokens::get(&ctx); let presenting = self.ui.screen_mode == ScreenMode::Presentation; + if presenting || self.ui.hidden_panels != 0 { + panel_docking::cancel_gesture(self, &ctx); + } let dbg = std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some(); if dbg { eprintln!("root start {:?}", ui.available_rect_before_wrap()); @@ -855,16 +883,29 @@ impl DesignApp { if !presenting { chrome::doc_tabs(self, ui); } - if self.split && !presenting { + let split_layout = (self.split && !presenting) + .then(|| { + craft_ui::layout::split_rect( + ui.available_rect_before_wrap(), + craft_ui::layout::SplitAxis::Horizontal, + craft_ui::layout::SplitSize::Ratio(0.5), + 2.0, + 0.0, + ) + }) + .flatten(); + if let Some(layout) = split_layout { // Two panes with a divider; each has its own view (zoom, scroll) and render cache. - let r = ui.available_rect_before_wrap(); - let mid = r.center().x; - let halves = [ - egui::Rect::from_min_max(r.min, egui::pos2(mid - 1.0, r.max.y)), - egui::Rect::from_min_max(egui::pos2(mid + 1.0, r.min.y), r.max), - ]; - ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(mid - 1.0, r.min.y), egui::pos2(mid + 1.0, r.max.y)), 0.0, t.border); - for (k, half) in halves.into_iter().enumerate() { + ui.painter().rect_filled(layout.divider, 0.0, t.border); + // Resolve the clicked pane before either canvas handles this frame's keys. + if let Some(p) = ui.input(|i| i.pointer.any_pressed().then(|| i.pointer.interact_pos()).flatten()) { + if layout.first.contains(p) { + self.focus_pane = 0; + } else if layout.second.contains(p) { + self.focus_pane = 1; + } + } + for (k, half) in [layout.first, layout.second].into_iter().enumerate() { self.switch_pane(k as u8); // A new pane starts at the other pane's view. if k == 1 && self.view().is_none() { @@ -877,9 +918,6 @@ impl DesignApp { } } ui.scope_builder(egui::UiBuilder::new().max_rect(half), |ui| canvas::show(self, ui)); - if ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| half.contains(p))) { - self.focus_pane = k as u8; - } } self.switch_pane(self.focus_pane); } else { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 94e5c09..d123ec4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -130,6 +130,22 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ "{panel: properties|pages|layers|swatches|paragraphStyles|characterStyles|stroke|character|paragraph|textWrap|links|table}", ), ("window.floatPanel", "Float Panel", None, "{panel, x?, y?} — the panel in its own movable window"), + ( + "window.panel.layout", + "Change Panel Layout", + None, + "{action} — serialized docking action, including floating geometry, divider sizes and accordion state", + ), + ( + "window.panel.move", + "Move Panel", + None, + "{panel, anchor, zone?: tab|center|left|right|top|bottom, before?: panel} — group, split or reorder a panel", + ), + ("window.panel.float", "Float Panel", None, "{panel, x?, y?, width?, height?} — detach a panel inside the application window"), + ("window.panel.dock", "Dock Panel", None, "{panel, anchor?, zone?, before?} — return a floating panel to a dock group"), + ("window.panel.activate", "Activate Panel", None, "{panel} — show and select a panel in its current group"), + ("window.panel.close", "Close Panel", None, "{panel} — hide a panel"), ("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"), ("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"), ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), @@ -707,6 +723,9 @@ pub fn ui_label(id: &str) -> Option<(&'static str, Option<&'static str>)> { /// Run a UI command; `None` if `id` isn't one. pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option> { + if let Some(result) = crate::panel_docking::command(app, id, p) { + return Some(result); + } let rect = app.canvas_rect; let flag = |b: &mut bool| { *b = !*b; @@ -1379,6 +1398,8 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Option { + app.ui.docking_generation = app.ui.docking_generation.wrapping_add(1); + app.ui.docking_raise = None; + app.ui.docking_opened = None; let name = p.get("name").and_then(Value::as_str).unwrap_or("Essentials"); if let Some(w) = app.ui.custom_workspaces.iter().find(|w| w.name == name).cloned() { app.ui.control_bar = w.control_bar; @@ -1412,10 +1436,17 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Some("properties"), @@ -2440,6 +2471,170 @@ mod tests { frame(&mut app); } + #[test] + fn split_window_dispatches_text_once_to_the_shared_document() { + for focus_pane in [0, 1] { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let made = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let story = made["story"].as_u64().unwrap(); + app.session.set_tool("type"); + app.run("text.select", json!({"story": story, "anchor": 0, "focus": 0})).unwrap(); + assert!(app.session.wants_text()); + app.run("window.split", json!({"on": true})).unwrap(); + app.focus_pane = focus_pane; + + let ctx = egui::Context::default(); + let frame = |app: &mut crate::DesignApp, events| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let undo_before = app.session.active().unwrap().history.undo.len(); + frame(&mut app, vec![egui::Event::Text("A".into())]); + let text = app.run("story.get", json!({"story": story})).unwrap(); + assert_eq!(text["text"], "A", "one key press with pane {focus_pane} focused"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], ""); + + // A press and key arriving together must use the newly focused pane, whichever + // canvas is drawn first. Middle-click changes pane focus without moving the caret. + let other = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center(); + frame(&mut app, vec![egui::Event::PointerMoved(other)]); + frame( + &mut app, + vec![ + egui::Event::PointerButton { pos: other, button: egui::PointerButton::Middle, pressed: true, modifiers: egui::Modifiers::NONE }, + egui::Event::Text("B".into()), + ], + ); + assert_eq!(app.focus_pane, 1 - focus_pane); + assert_eq!(app.run("story.get", json!({"story": story})).unwrap()["text"], "B", "a key in the pane-switch frame is dispatched once"); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo_before + 1); + } + } + + #[test] + fn split_window_click_focus_routes_menu_zoom_but_hover_scroll_stays_local() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("window.split", json!({"on": true})).unwrap(); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut frame = |app: &mut crate::DesignApp, events| { + time += 0.1; + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), + time: Some(time), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + }; + for _ in 0..3 { + frame(&mut app, vec![]); + } + let left = app.canvas_rect.unwrap().center(); + let right = app.other_pane.as_ref().and_then(|p| p.1).unwrap().center(); + let cameras = |app: &mut crate::DesignApp| { + let current = app.pane; + app.switch_pane(0); + let left = *app.view().unwrap(); + app.switch_pane(1); + let right = *app.view().unwrap(); + app.switch_pane(current); + [left, right] + }; + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, vec![egui::Event::PointerMoved(right)]); + frame(&mut app, vec![button(right, true)]); + frame(&mut app, vec![button(right, false)]); + assert_eq!((app.pane, app.focus_pane), (1, 1)); + let before = cameras(&mut app); + app.run("view.zoom", json!({"zoom": 2.0})).unwrap(); + let zoomed = cameras(&mut app); + assert_eq!(zoomed[0], before[0], "menu zoom leaves the other camera alone"); + assert!((zoomed[1].zoom - 2.0).abs() < 1e-9); + + frame(&mut app, vec![egui::Event::PointerMoved(left)]); + frame( + &mut app, + vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }], + ); + let scrolled = cameras(&mut app); + assert_eq!(scrolled[1], zoomed[1], "hover scroll leaves the focused camera alone"); + assert_ne!(scrolled[0].origin, zoomed[0].origin); + assert_eq!((app.pane, app.focus_pane), (1, 1), "hovering does not move menu focus"); + + frame(&mut app, vec![button(left, true)]); + frame(&mut app, vec![button(left, false)]); + assert_eq!((app.pane, app.focus_pane), (0, 0)); + app.run("view.zoom", json!({"zoom": 1.0})).unwrap(); + let final_views = cameras(&mut app); + assert!((final_views[0].zoom - 1.0).abs() < 1e-9); + assert_eq!(final_views[1], scrolled[1]); + } + + #[test] + fn split_window_contextual_bars_have_independent_widget_ids() { + fn id_warnings(shape: &egui::Shape, found: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| id_warnings(shape, found)), + egui::Shape::Text(text) if text.galley.text().contains("use of widget ID") => found.push(text.galley.text().to_string()), + _ => {} + } + } + + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + for rect in [[36, 36, 280, 180], [300, 36, 550, 180]] { + app.run("frame.create", json!({"rect": rect, "content": "unassigned"})).unwrap(); + } + app.run("edit.selectAll", json!({})).unwrap(); + app.run("window.split", json!({"on": true})).unwrap(); + let ctx = egui::Context::default(); + ctx.options_mut(|options| options.warn_on_id_clash = true); + let mut warnings = Vec::new(); + for _ in 0..4 { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + warnings.clear(); + for shape in &output.shapes { + id_warnings(&shape.shape, &mut warnings); + } + output.textures_delta.clear(); + } + assert!(warnings.is_empty(), "both bars reused the same interactive widget IDs: {warnings:?}"); + let bars: Vec<_> = + (0_u8..2).map(|pane| ctx.memory(|memory| memory.area_rect(egui::Id::new(("task_bar", egui::ViewportId::ROOT, pane)))).unwrap()).collect(); + assert!(!bars[0].intersects(bars[1]), "each pane keeps its own bar: {bars:?}"); + } + #[test] fn interface_language_switches_and_validates() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs new file mode 100644 index 0000000..e53986c --- /dev/null +++ b/crates/ui-egui/src/panel_docking.rs @@ -0,0 +1,515 @@ +//! 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::DesignApp; + +#[derive(Clone, Default)] +struct LegacyDrag { + layout: Option>, + open_panel: Option, + floating: Vec<(String, [f32; 2])>, + generation: u64, +} + +pub(crate) fn cancel_gesture(app: &mut DesignApp, ctx: &egui::Context) { + cancel_legacy_drag(app, ctx); + craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); +} + +fn cancel_legacy_drag(app: &mut DesignApp, ctx: &egui::Context) -> bool { + let key = egui::Id::new("designcraft-panel-docking").with("legacy-origin"); + let previous = ctx.data_mut(|data| data.remove_temp::(key)); + if let Some(previous) = previous { + craft_ui::docking::cancel_drag::(ctx, egui::Id::new("designcraft-panel-docking")); + if previous.generation != app.ui.docking_generation { + return false; + } + app.ui.open_panel = previous.open_panel; + app.ui.floating = previous.floating; + app.ui.docking = previous.layout; + true + } else { + false + } +} + +fn normalize(raw: &str) -> Option<&'static str> { + crate::dock::DOCK_TABS + .iter() + .chain(crate::dock::ICON_PANELS) + .find(|(id, label, _)| id.eq_ignore_ascii_case(raw.trim()) || label.eq_ignore_ascii_case(raw.trim())) + .map(|(id, _, _)| *id) +} + +pub(crate) fn valid(layout: &Layout) -> bool { + layout.validate().is_ok() && layout.panels().iter().all(|id| normalize(id) == Some(id.as_str())) +} + +fn legacy(app: &DesignApp) -> Layout { + let panels: Vec = crate::dock::DOCK_TABS.iter().map(|p| p.0.to_string()).collect(); + let active = panels.iter().position(|p| p == &app.ui.dock_tab).unwrap_or(0); + let mut layout = Layout { root: Some(Node::Tabs { panels, active }), floating: Vec::new() }; + for (id, at) in &app.ui.floating { + if normalize(id).is_some() && !layout.contains(id) && at.iter().all(|v| v.is_finite()) { + layout.floating.push(Floating { panels: vec![id.clone()], active: 0, rect: [at[0], at[1], 280.0, 360.0] }); + } + } + if let Some(id) = &app.ui.open_panel + && normalize(id).is_some() + && !layout.contains(id) + { + // Preserve the visible flyout when the first unrelated docking gesture succeeds. + if let Some(Node::Tabs { panels, active }) = &mut layout.root { + panels.push(id.clone()); + *active = panels.len().saturating_sub(1); + } + } + layout +} + +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() +} + +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 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 DesignApp, id: &str, params: &Value) -> Option> { + if id == "app.tablePanel" && app.ui.docking.is_some() { + let panel = "table".to_string(); + let visible = app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&panel)); + return Some(change(app, if visible { "close" } else { "activate" }, &json!({"panel": panel}))); + } + if id == "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)), + ); + } + let operation = match id { + "window.panel.move" => "move", + "window.panel.float" | "window.floatPanel" => "float", + "window.panel.dock" | "window.dockPanel" => "dock", + "window.panel.activate" => "activate", + "window.panel.close" => "close", + "window.panel" if app.ui.docking.is_some() => "activate", + _ => return None, + }; + Some(change(app, operation, params)) +} + +// UI output and automation share this atomic dispatcher. The application owns the registry +// and saved return locations; craft-ui owns tree validation and structural changes. +fn apply_action(app: &mut DesignApp, action: Action) -> Result { + let registered = |id: &String| normalize(id) == Some(id.as_str()); + let known = match &action { + 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 returning = match &action { + Action::Close { panel } => layout.location(panel).ok().map(|location| (panel.clone(), location)), + Action::Float { panel, .. } => { + layout.location(panel).ok().filter(|location| location.floating.is_none()).map(|location| (panel.clone(), location)) + } + _ => None, + }; + let reveal = match &action { + Action::Activate { panel } | Action::Open { panel, .. } | Action::OpenAt { panel, .. } => Some(panel.clone()), + _ => None, + }; + layout.apply(action).map_err(|error| error.to_string())?; + if let Some((panel, location)) = returning { + app.ui.docking_hidden.insert(panel, location); + } + app.ui.docking = Some(layout); + sync_legacy(app); + if let Some(panel) = reveal { + app.ui.docking_opened = Some(panel.clone()); + app.ui.docking_raise = Some(panel); + app.ui.dock_expanded = true; + } else if app.ui.docking_opened.as_ref().is_some_and(|panel| !app.ui.docking.as_ref().is_some_and(|layout| layout.contains(panel))) { + app.ui.docking_opened = None; + } + app.ui.open_panel = None; + Ok(json!({"layout": app.ui.docking})) +} + +fn change(app: &mut DesignApp, operation: &str, params: &Value) -> Result { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { + return Err("saved panel layout is invalid; reset the workspace first".into()); + } + let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); + let mut hidden: std::collections::BTreeMap<_, _> = + app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); + let mut restored = false; + if operation == "dock" + && params.get("anchor").or_else(|| params.get("onto")).is_none() + && let Some(location) = hidden.get(panel).filter(|location| { + location.floating.is_none() + && location.anchor.as_ref().is_none_or(|anchor| Layout { root: layout.root.clone(), floating: Vec::new() }.contains(anchor)) + }) + { + let mut candidate = layout.clone(); + if candidate.contains(&panel.to_string()) { + candidate.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + if candidate.restore(panel.into(), location).is_ok() { + layout = candidate; + restored = true; + hidden.remove(panel); + } + } + match operation { + "dock" if restored => {} + "float" => { + let rect = [number(params, "x", 400.0)?, number(params, "y", 140.0)?, number(params, "width", 300.0)?, number(params, "height", 400.0)?]; + ensure(&mut layout, panel)?; + if let Ok(location) = layout.location(&panel.to_string()) + && location.floating.is_none() + { + hidden.insert(panel.into(), location); + } + layout.apply(Action::Float { panel: panel.into(), rect }).map_err(|e| e.to_string())?; + } + "move" | "dock" => { + if operation == "move" && params.get("anchor").or_else(|| params.get("onto")).is_none() { + return Err("move requires a destination panel".into()); + } + let anchor = match params.get("anchor").or_else(|| params.get("onto")) { + Some(v) => Some(v.as_str().and_then(normalize).ok_or("anchor must name a registered panel")?.to_string()), + None => first_root(&layout, panel), + }; + let placement = placement(params)?; + if let Some(anchor) = anchor { + ensure(&mut layout, panel)?; + layout.apply(Action::Move { panel: panel.into(), anchor, placement }).map_err(|e| e.to_string())?; + } else if operation == "dock" { + if layout.contains(&panel.to_string()) { + layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + layout.apply(Action::Open { panel: panel.into(), anchor: None }).map_err(|e| e.to_string())?; + } else { + return Err("move requires an existing destination panel".into()); + } + } + "close" => { + hidden.insert(panel.into(), layout.location(&panel.to_string()).map_err(|e| e.to_string())?); + layout.apply(Action::Close { panel: panel.into() }).map_err(|e| e.to_string())?; + } + _ => { + if !layout.contains(&panel.to_string()) + && let Some(location) = hidden.get(panel) + { + // A missing neighbor is allowed: the normal visible dock becomes the fallback. + let _ = layout.restore(panel.into(), location); + } + ensure(&mut layout, panel)?; + hidden.remove(panel); + } + } + app.ui.docking = Some(layout); + app.ui.docking_hidden = hidden; + sync_legacy(app); + app.ui.open_panel = None; + app.ui.docking_opened = if operation == "close" { None } else { Some(panel.to_owned()) }; + app.ui.docking_raise = if operation == "close" { None } else { Some(panel.to_owned()) }; + if operation != "close" { + app.ui.dock_expanded = true; + } + Ok(json!({"panel": panel, "floating": app.ui.floating.iter().map(|(id, _)| id).collect::>(), "layout": app.ui.docking})) +} + +pub(crate) fn show(app: &mut DesignApp, ui: &mut egui::Ui) -> bool { + let area = egui::Id::new("designcraft-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.status("Invalid panel layout; restored the default dock"); + return false; + } + let mut layout = layout.clone(); + if !app.ui.dock_expanded { + layout.root = None; + } + let raise = app.ui.docking_raise.take(); + let mut blocked = Vec::new(); + if app.ui.dialog.is_some() || app.ui.about || app.ui.palette.is_some() || egui::Popup::is_any_open(ui.ctx()) { + blocked.push(ui.ctx().content_rect()); + } else if app.story_editor.is_some() { + // The editor is rendered after the dock; block its unmeasured first frame conservatively. + let rect = ui.ctx().memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap_or_else(|| ui.ctx().content_rect()); + blocked.push(rect); + } + let t = crate::theme::Tokens::get(ui.ctx()); + let language = app.ui.language.clone(); + let mut draw = |ui: &mut egui::Ui| { + let mut style = craft_ui::docking::DockStyle::from_ui(ui); + style.tab_height = 27.0; + style.background = t.panel; + style.tab_background = t.tab_strip; + style.active_background = t.panel; + style.text = t.text_strong; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.float_label = crate::i18n::tr(&language, "Float Panel").to_owned(); + style.close_label = crate::i18n::tr(&language, "Close Panel").to_owned(); + style.move_label = crate::i18n::tr(&language, "Group with").to_owned(); + style.panels_label = crate::i18n::tr(&language, "Panels").to_owned(); + style.resize_label = crate::i18n::tr(&language, "Resize panels").to_owned(); + style.resize_window_label = crate::i18n::tr(&language, "Resize panel window").to_owned(); + let mut area = craft_ui::docking::DockArea::new(egui::Id::new("designcraft-panel-docking")).blocked_rects(&blocked); + if let Some(panel) = &raise { + area = area.raise_panel(panel.clone()); + } + area.show_customized( + ui, + &layout, + &style, + |id| { + crate::i18n::tr( + &language, + crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS).find(|p| p.0 == id).map(|p| p.1).unwrap_or(id), + ) + .to_owned() + }, + |_| craft_ui::docking::Permissions::default(), + |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, + &mut PanelContent(app), + ) + }; + let output = if layout.root.is_some() { + egui::Panel::right("shared-panel-dock") + .default_size(310.0) + .size_range(220.0..=720.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.panel)) + .show(ui, &mut draw) + .inner + } else { + draw(ui) + }; + if let Some(panel) = output.focused { + app.ui.docking_opened = Some(panel); + } + if let Some(error) = output.error { + app.ui.status = error.to_string(); + } + if ui.input(|input| input.pointer.any_released()) { + if !output.actions.iter().any(|action| matches!(action, Action::Move { .. } | Action::Float { .. })) && cancel_legacy_drag(app, ui.ctx()) { + return app.ui.docking.is_some(); + } + ui.ctx().data_mut(|data| data.remove::(egui::Id::new("designcraft-panel-docking").with("legacy-origin"))); + } + for action in output.actions { + if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { + app.ui.status = error; + } + } + icon_rail(app, ui); + true +} + +fn icon_rail(app: &mut DesignApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + let column = egui::Panel::right("shared-panel-icons") + .exact_size(38.0) + .resizable(false) + .frame(egui::Frame::NONE.fill(t.panel)) + .show(ui, |ui| { + egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for &(id, label, icon) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if app.ui.docking.as_ref().is_some_and(|layout| { + layout.contains(&id.to_string()) && (app.ui.dock_expanded || layout.floating.iter().any(|g| g.panels.iter().any(|p| p == id))) + }) { + continue; + } + + let response = + crate::icons::button(ui, icon, 28.0, false, crate::i18n::tr(&app.ui.language, label)).interact(egui::Sense::click_and_drag()); + if response.clicked() { + let _ = app.run("window.panel.activate", json!({"panel":id})); + } + legacy_tab(app, ui, id, &response); + } + }); + }) + .response + .rect; + let _ = column; +} + +pub(crate) fn legacy_tab(app: &mut DesignApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { + let Some(panel) = normalize(panel) else { return }; + let language = app.ui.language.clone(); + response.context_menu(|ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Float Panel"))).clicked() { + if let Err(error) = app.run("window.panel.float", json!({"panel": panel})) { + app.status(error); + } + ui.close(); + } + ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&language, "Group with")), |ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for (anchor, label, _) in crate::dock::DOCK_TABS.iter().chain(crate::dock::ICON_PANELS) { + if *anchor != panel && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&language, 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.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("designcraft-panel-docking"), id.to_string(), source) + { + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("designcraft-panel-docking").with("legacy-origin"), + LegacyDrag { + layout: app.ui.docking.clone(), + open_panel: app.ui.open_panel.clone(), + floating: app.ui.floating.clone(), + generation: app.ui.docking_generation, + }, + ) + }); + app.ui.docking = Some(layout); + app.ui.open_panel = None; + } + } +} + +struct PanelContent<'a>(&'a mut DesignApp); +impl craft_ui::docking::DockContent for PanelContent<'_> { + fn body(&mut self, ui: &mut egui::Ui, id: &String) { + egui::Frame::NONE.inner_margin(egui::Margin::same(8)).show(ui, |ui| { + egui::ScrollArea::vertical() + .id_salt(("shared-panel-body", id)) + .auto_shrink([false, false]) + .show(ui, |ui| crate::dock::panel_body(self.0, ui, id)); + }); + } + fn label(&mut self, ui: &egui::Ui, _: &String, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { + egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color)) + } + fn action_label(&mut self, ui: &egui::Ui, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { + egui::WidgetText::Galley(crate::rtl::plain(ui.ctx(), logical, font.clone(), color)) + } +} + +/// Close the last explicitly opened panel through the same validated layout transaction. +pub(crate) fn close_visible(app: &mut DesignApp) -> Result<(), String> { + if app.ui.docking.is_some() { + if let Some(panel) = app.ui.docking_opened.clone() { + change(app, "close", &json!({"panel": panel}))?; + } + } else { + app.ui.open_panel = None; + } + Ok(()) +} + +// Legacy inspection fields are derived from the shared layout after customization. +fn sync_legacy(app: &mut DesignApp) { + if let Some(layout) = &app.ui.docking { + app.ui.floating = + layout.floating.iter().flat_map(|group| group.panels.iter().map(|id| (id.clone(), [group.rect[0], group.rect[1]]))).collect(); + } +} + +#[cfg(test)] +#[path = "panel_docking_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs new file mode 100644 index 0000000..210ae0c --- /dev/null +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -0,0 +1,599 @@ +use super::*; +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; + +fn app() -> DesignApp { + DesignApp::new(designcraft_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::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: DesignApp, size: egui::Vec2) -> Harness<'static, DesignApp> { + 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 DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::MediumDark)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |_| {}); + }, + app, + ); + harness.run_steps(4); + harness +} + +fn drag(h: &mut Harness<'static, DesignApp>, 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("designcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let destination = h.ctx.read_response(egui::Id::new("designcraft-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.newWorkspace", 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); +} + +/// 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("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Synthetic layout — مرحبا"})).unwrap(); + let language = if width == 800.0 && scale == 1.0 { "ar" } else { "en" }; + app.run("app.language", json!({"lang":if language == "en" { "" } else { language }})).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 DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(theme)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Custom panel workspace"); + if app.ui.language == "ar" { + ui.label("Shaped action references:"); + for action in ["Float Panel", "Group with", "Close Panel"] { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, action)); + } + } + }); + }, + 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!("designcraft-shared-docking-{}-{language}-{width}-{scale}x.png", theme.id()))) + .unwrap(); + if language == "ar" { + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"swatches".to_string())); + let at = harness.ctx.read_response(tab).unwrap().rect.center(); + harness.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + harness.run_steps(1); + harness.input_mut().events.push(Event::PointerButton { + pos: at, + button: PointerButton::Secondary, + pressed: false, + modifiers: Modifiers::NONE, + }); + harness.run_steps(2); + assert!(egui::Popup::is_any_open(&harness.ctx), "actual tab context menu must be open"); + for action in ["Float Panel", "Group with", "Close Panel"] { + assert!( + harness.query_all_by_label(crate::i18n::tr("ar", action)).count() >= 2, + "both logical reference and actual menu label must exist" + ); + } + harness.render().unwrap().save(directory.join(format!("designcraft-dock-context-{}-ar.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("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + app.run("object.fill", json!({"swatch":"C=100 M=0 Y=0 K=0"})).unwrap(); + app.run("object.stroke", 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 DesignApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(5); + let at = h.get_by_label("Bevel").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); + assert_eq!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); + if let Some(directory) = &directory { + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join("designcraft-populated-stroke-floating.png")).unwrap(); + } + let from = h.ctx.read_response(egui::Id::new("designcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); + let to = h.ctx.read_response(egui::Id::new("designcraft-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!(h.state().session.active().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().stroke.join, designcraft_doc::Join::Bevel); + if let Some(directory) = &directory { + h.render().unwrap().save(directory.join("designcraft-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.newWorkspace", 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 first_customization_preserves_the_visible_legacy_flyout() { + let mut app = app(); + app.ui.open_panel = Some("paragraphStyles".into()); + app.ui.dock_expanded = false; + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let layout = app.ui.docking.as_ref().unwrap(); + assert!(layout.contains(&"paragraphStyles".into())); + assert!(valid(layout)); + assert_eq!(layout.panels().iter().filter(|p| p.as_str() == "paragraphStyles").count(), 1); +} + +#[test] +fn hiding_before_first_destination_frame_does_not_restore_an_old_drag_snapshot() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches","anchor":"layers","zone":"top"})).unwrap(); + app.run("window.newWorkspace", 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(); + let destination = h.state().ui.docking.clone(); + let open_panel = h.state().ui.open_panel.clone(); + let floating = h.state().ui.floating.clone(); + // The app calls this before dock rendering when panels are hidden or Presentation starts. + let ctx = h.ctx.clone(); + cancel_gesture(h.state_mut(), &ctx); + assert_eq!(h.state().ui.docking, destination); + assert_eq!(h.state().ui.open_panel, open_panel); + assert_eq!(h.state().ui.floating, floating); + 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 legacy_table_and_control_visibility_work_after_customization_without_document_edits() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let undo = app.session.active().unwrap().history.undo.len(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.run("app.tablePanel", json!({})).unwrap(); + assert!(app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("app.tablePanel", json!({})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("app.tablePanel", json!({})).unwrap(); + let ctx = egui::Context::default(); + let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"closePanel":true,"dockExpanded":false})); + let crate::control::Outcome::Done(result) = crate::control::handle(&mut app, &ctx, &req) else { panic!("expected synchronous control result") }; + assert_eq!(result["ok"], true); + assert!(!app.ui.dock_expanded); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"table".into())); + app.run("window.panel", json!({"panel":"properties"})).unwrap(); + assert!(app.ui.dock_expanded); + assert_eq!(app.ui.docking_raise.as_deref(), Some("properties")); + assert_eq!(serde_json::to_value(&app.session.active().unwrap().doc).unwrap(), doc); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); +} + +#[test] +fn floating_activation_queues_one_transient_raise_and_workspace_switch_clears_it() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + app.run("window.panel.activate", json!({"panel":"layers"})).unwrap(); + assert_eq!(app.ui.docking_raise.as_deref(), Some("layers")); + assert!(serde_json::to_value(&app.ui).unwrap().get("docking_raise").is_none()); + app.run("window.workspace", json!({"name":"Typography"})).unwrap(); + assert!(app.ui.docking_raise.is_none()); +} + +#[test] +fn shaped_panel_label_keeps_the_logical_accessibility_text() { + let ctx = egui::Context::default(); + let logical = "خصائص Pages"; + crate::theme::install_fonts(&ctx, "ar"); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = crate::rtl::plain(ui.ctx(), logical, egui::FontId::proportional(12.0), egui::Color32::WHITE); + assert_eq!(galley.job.text, logical); + }); + output.textures_delta.clear(); +} + +#[test] +fn modal_overlay_blocks_a_real_tab_drag_release() { + let mut app = app(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let area = egui::Id::new("designcraft-panel-docking"); + let source = h.ctx.read_response(area.with(("tab", &"properties".to_string()))).unwrap().rect.center(); + h.event(Event::PointerMoved(source)); + h.run_steps(1); + h.event(Event::PointerButton { pos: source, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerMoved(source - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(area.with(("tab", &"properties".to_string()))), "test must start the owned tab gesture"); + let before = h.state().ui.docking.clone(); + h.state_mut().ui.dialog = Some(crate::dialogs::Dialog::new("newDocument", json!({}))); + let destination = egui::pos2(150.0, 150.0); + h.event(Event::PointerMoved(destination)); + h.run_steps(1); + h.event(Event::PointerButton { pos: destination, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().ui.docking, before, "release under a modal must not float or dock the panel"); + h.state_mut().ui.dialog = None; + h.run_steps(2); + assert_eq!(h.state().ui.docking, before, "closing the overlay must not revive its blocked drag"); +} + +#[test] +fn shift_f9_toggles_table_after_customization_through_the_real_shortcut_path() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + for visible in [true, false] { + let modifiers = Modifiers { shift: true, ..Modifiers::NONE }; + h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(1); + h.event(Event::Key { key: egui::Key::F9, physical_key: None, pressed: false, repeat: false, modifiers }); + h.run_steps(2); + assert_eq!(h.state().app.ui.docking.as_ref().unwrap().contains(&"table".into()), visible); + } + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), before); +} + +#[test] +fn first_story_editor_frame_conservatively_blocks_owned_tab_release() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + let story = app.run("frame.create", json!({"rect":[72,72,300,200],"content":"text","text":"Story editor overlay fixture"})).unwrap()["story"] + .as_u64() + .unwrap(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut h = crate::test_window::open(app, vec2(1440.0, 900.0)); + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"properties".to_string())); + let from = h.ctx.read_response(tab).unwrap().rect.center(); + 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); + h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(tab), "must own a real tab drag before opening editor"); + assert!(h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).is_none()); + let layout = h.state().app.ui.docking.clone(); + let frame_before = h.ctx.cumulative_frame_nr(); + h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); + // The unmeasured first frame blocks the full content, even outside the eventual editor. + let release = egui::pos2(150.0, 150.0); + h.input_mut().events.extend([ + Event::PointerMoved(release), + Event::PointerButton { pos: release, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + assert_eq!(h.ctx.cumulative_frame_nr() - frame_before, 1, "release must occur in the first editor frame"); + let measured = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap(); + assert!(!measured.contains(release), "fixture exercises the conservative first-frame guard outside eventual editor bounds"); + assert_eq!(h.state().app.ui.docking, layout, "first editor frame must block release before it has a measured rectangle"); + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); + h.run_steps(3); + let editor = h.ctx.memory(|m| m.area_rect(egui::Id::new("story_editor"))).unwrap(); + let screen = h.ctx.content_rect(); + let outside = [screen.left_bottom() + vec2(80.0, -80.0), screen.right_bottom() + vec2(-450.0, -80.0)] + .into_iter() + .find(|p| !editor.contains(*p)) + .expect("fixture has space outside editor"); + let before_outside = h.state().app.ui.docking.clone(); + let tab = egui::Id::new("designcraft-panel-docking").with(("tab", &"layers".to_string())); + let from = h.ctx.read_response(tab).unwrap().rect.center(); + 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); + h.event(Event::PointerMoved(from - vec2(90.0, 0.0))); + h.run_steps(2); + assert_eq!(h.ctx.dragged_id(), Some(tab)); + h.event(Event::PointerMoved(outside)); + h.event(Event::PointerButton { pos: outside, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert_ne!(h.state().app.ui.docking, before_outside, "measured editor must permit ordinary releases outside its bounds"); + let after = h.state().app.ui.docking.clone(); + h.state_mut().app.run("app.storyEditor", json!({"story":story})).unwrap(); + h.run_steps(3); + assert_eq!(h.state().app.ui.docking, after, "closing editor must not revive a blocked drag"); + assert_eq!(serde_json::to_value(&h.state().app.session.active().unwrap().doc).unwrap(), doc); +} + +#[test] +fn capture_legacy_panel_menu_visual_fixture() { + let Some(directory) = std::env::var_os("CRAFT_UI_DOCKING_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect":[40,40,280,180],"content":"text","text":"Legacy Arabic menu fixture"})).unwrap(); + app.run("app.language", json!({"lang":"ar"})).unwrap(); + let doc = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + let mut ready = false; + let mut h = Harness::builder().with_size(vec2(800.0, 800.0)).wgpu().build_ui_state( + move |ui, app: &mut DesignApp| { + if !ready { + crate::theme::install_fonts(ui.ctx(), &app.ui.language); + crate::theme::apply(ui.ctx(), &crate::theme::Tokens::for_brightness(crate::theme::Brightness::Dark)); + ready = true; + return; + } + crate::dock::show(app, ui); + egui::CentralPanel::default().show(ui, |ui| { + ui.label("Shaped legacy menu references:"); + for label in ["Float Panel", "Layers"] { + crate::rtl::label(ui, crate::i18n::tr("ar", label)); + } + }); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + h.run_steps(5); + assert!(h.state().ui.docking.is_none(), "fixture must use the actual legacy menu before customization"); + let at = h.get_by_label("Properties").rect().center(); + h.input_mut().events.extend([ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }, + ]); + h.run_steps(1); + h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); + assert!(egui::Popup::is_any_open(&h.ctx)); + assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Float Panel")).count(), 2, "reference and actual logical action must be accessible"); + h.render().unwrap().save(directory.join("designcraft-legacy-context-dark-ar.png")).unwrap(); + let group = h.get_by_label_contains(crate::i18n::tr("ar", "Group with")).rect().center(); + h.hover_at(group); + h.run_steps(4); + assert_eq!(h.query_all_by_label(crate::i18n::tr("ar", "Layers")).count(), 2, "reference and actual target must be accessible"); + h.render().unwrap().save(directory.join("designcraft-legacy-targets-dark-ar.png")).unwrap(); + assert!(h.state().ui.docking.is_none()); + let (last, last_label, _) = crate::dock::ICON_PANELS.last().unwrap(); + let logical = crate::i18n::tr("ar", last_label); + let scroll_at = h.query_all_by_label(crate::i18n::tr("ar", "Layers")).find(|node| node.rect().left() > 500.0).unwrap().rect().center(); + let submenu_layer = h.ctx.layer_id_at(scroll_at).unwrap(); + let submenu = h.ctx.memory(|m| m.area_rect(submenu_layer.id)).unwrap(); + let before = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).map(|node| node.rect()); + assert!(before.is_none_or(|rect| !submenu.contains_rect(rect)), "actual submenu must require scrolling to reach its last target"); + h.input_mut().events.extend([ + Event::PointerMoved(scroll_at), + Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, -1600.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE }, + ]); + h.run_steps(12); + let target = h.query_all_by_label(logical).find(|node| submenu.x_range().contains(node.rect().center().x)).unwrap().rect(); + assert!(submenu.contains_rect(target), "last target must be inside the actual submenu after scrolling"); + assert!(h.ctx.content_rect().contains_rect(target), "last target must be physically visible after wheel scrolling"); + h.render().unwrap().save(directory.join("designcraft-legacy-targets-scrolled-dark-ar.png")).unwrap(); + h.query_all_by_label(logical).find(|node| submenu.contains_rect(node.rect())).unwrap().click(); + h.run_steps(4); + let layout = h.state().ui.docking.as_ref().unwrap(); + assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == "properties").count(), 1); + assert_eq!(layout.panels().iter().filter(|panel| panel.as_str() == *last).count(), 1); + let group = group_members_for_test(layout, last); + assert!(group.iter().any(|panel| panel == "properties")); + assert_eq!(group.last().map(String::as_str), Some("properties"), "clicked destination must receive the original panel after it"); + assert_eq!(serde_json::to_value(&h.state().session.active().unwrap().doc).unwrap(), doc); + h.render().unwrap().save(directory.join("designcraft-legacy-last-target-selected-dark-ar.png")).unwrap(); +} diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 9b25afd..cf66e9d 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -16,7 +16,7 @@ const GRIP: &str = "task_bar_grip"; /// Show the bar under `sel` (screen rect of the selection), or where it is pinned, kept inside /// `canvas`. pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { - let grip_id = egui::Id::new(GRIP); + let grip_id = egui::Id::new((GRIP, ctx.viewport_id(), app.pane)); // (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.) let dragging = ctx.dragged_id() == Some(grip_id); if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui() && !dragging) { @@ -28,7 +28,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { return; } let t = Tokens::get(ctx); - let id = egui::Id::new("task_bar"); + // The same selection can be shown in two canvases; each bar needs its own area and widgets. + let id = egui::Id::new(("task_bar", ctx.viewport_id(), app.pane)); // Width from the previous frame (content-sized). let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0); let at = match app.ui.task_bar_pin { @@ -46,7 +47,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { pos2(x, y) } }; - app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]); + // A shared persisted pin is relative to each canvas. The transient pin target belongs + // to the focused pane, so rendering the sibling cannot overwrite it. + if app.pane == app.focus_pane { + app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]); + } egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(ctx, |ui| { egui::Frame::NONE .fill(t.panel) @@ -75,7 +80,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { } else { object_controls(app, ui); } - more(app, ui); + more(app, ui, [at.x - canvas.min.x, at.y - canvas.min.y]); }); }); }); @@ -266,7 +271,7 @@ fn object_controls(app: &mut DesignApp, ui: &mut Ui) { } } -fn more(app: &mut DesignApp, ui: &mut Ui) { +fn more(app: &mut DesignApp, ui: &mut Ui, at: [f32; 2]) { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(22.0, FIELD_H), Sense::click()); if resp.hovered() { @@ -286,7 +291,8 @@ fn more(app: &mut DesignApp, ui: &mut Ui) { let pinned = app.ui.task_bar_pin.is_some(); let pin = crate::i18n::tr(&app.ui.language, "Pin Bar Position"); if ui.button(crate::rtl::widget(ui, if pinned { format!("✓ {pin}") } else { format!(" {pin}") })).clicked() { - let _ = app.run("window.taskBarPin", json!({"on": !pinned})); + let params = if pinned { json!({"on": false}) } else { json!({"on": true, "at": at}) }; + let _ = app.run("window.taskBarPin", params); ui.close(); } if ui.add_enabled(pinned, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset Bar Position")))).clicked() { @@ -323,7 +329,7 @@ mod tests { } fn bar(h: &Harness<'static, Window>) -> egui::Rect { - h.ctx.memory(|m| m.area_rect(egui::Id::new("task_bar"))).unwrap() + h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), h.state().app.pane)))).unwrap() } fn grip(h: &Harness<'static, Window>) -> egui::Pos2 { @@ -372,6 +378,59 @@ mod tests { assert_eq!(app.ui.task_bar_pin, Some([5.0, 6.0])); } + #[test] + fn split_pane_grips_handle_only_their_own_drag() { + let (mut h, _) = window(); + h.set_size(vec2(2200.0, 900.0)); + // A compact object HUD fits in each pane, leaving room to exercise both axes + // without the intentional canvas-edge clamp masking horizontal movement. + let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap(); + h.state_mut().app.run("window.split", json!({"on":true})).unwrap(); + h.run_steps(6); + let area = + |h: &Harness<'static, Window>, pane| h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), pane)))).unwrap(); + for pane in [0_u8, 1] { + h.state_mut().app.run("window.taskBarPin", json!({"at":[100,120]})).unwrap(); + h.run_steps(4); + let before = area(&h, pane); + let from = pos2(before.min.x + 10.0, before.center().y); + let delta = vec2(24.0, 28.0); + drag(&mut h, from, delta); + let after = area(&h, pane); + assert!((after.min - before.min - delta).length() < 2.0, "pane {pane} drag was handled by a sibling grip: {before:?} -> {after:?}"); + assert!(h.state().app.ui.task_bar_pin.is_some()); + } + } + + #[test] + fn popup_pin_uses_the_invoking_hud_position_after_focus_moves_to_sibling() { + let (mut h, _) = window(); + let id = h.state_mut().app.run("frame.create", json!({"rect":[72,72,300,200],"content":"none"})).unwrap()["id"].as_u64().unwrap(); + h.state_mut().app.run("selection.set", json!({"ids":[id]})).unwrap(); + h.state_mut().app.run("window.split", json!({"on":true})).unwrap(); + h.run_steps(6); + h.state_mut().app.switch_pane(1); + h.state_mut().app.run("view.zoom", json!({"zoom":0.5})).unwrap(); + h.state_mut().app.switch_pane(0); + h.run_steps(4); + let expected = h.state().app.ui.task_bar_at.unwrap(); + let left = h.ctx.memory(|m| m.area_rect(egui::Id::new(("task_bar", h.ctx.viewport_id(), 0_u8)))).unwrap(); + click_at(&mut h, pos2(left.max.x - 19.0, left.center().y)); + let pin = h.get_by_label_contains("Pin Bar Position").rect(); + let at = pos2(pin.max.x - 2.0, pin.center().y); + let sibling = h.state().app.other_pane.as_ref().and_then(|(_, rect)| *rect).unwrap(); + assert!(sibling.contains(at), "fixture popup must extend into sibling canvas: {at:?} outside {sibling:?}"); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + assert_eq!(h.state().app.focus_pane, 1); + assert_ne!(h.state().app.ui.task_bar_at, Some(expected), "fixture HUD positions must differ"); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + assert_eq!(h.state().app.ui.task_bar_pin, Some(expected), "Pin must use the invoking HUD position"); + } + #[test] fn the_grip_moves_the_bar_and_it_stays_there() { let (mut h, id) = window(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 3dd5018..193abf3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -332,18 +332,11 @@ pub fn apply(ctx: &egui::Context, t: &Tokens) { v.selection.stroke = Stroke::new(1.0, t.text_strong); v.hyperlink_color = t.accent; v.override_text_color = Some(t.text); - for (w, fill) in [ - (&mut v.widgets.noninteractive, t.panel), - (&mut v.widgets.inactive, t.button), - (&mut v.widgets.hovered, t.hover), - (&mut v.widgets.active, t.tool_active), - (&mut v.widgets.open, t.hover), - ] { - w.bg_fill = fill; - w.weak_bg_fill = fill; - w.corner_radius = r; - w.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( + &mut v.widgets, + r, + Stroke::new(1.0, t.text), + ); v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.field_border); v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_border); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d3b6f62..d97c2ba 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -3,6 +3,17 @@ Start the app with `--control ` (or `DESIGNCRAFT_CONTROL_PORT`). The server listens on `127.0.0.1` only and speaks JSON lines: one request object per line, one reply per line. +For an isolated native test profile, set `DESIGNCRAFT_CONFIG_DIR` to a dedicated directory before +launch. UI/engine preferences and the `Recovery` directory then live under that root. An unset +override preserves the usual platform paths; an explicitly empty override disables those paths. +`DESIGNCRAFT_NO_PREFS` still disables preference reads/writes independently of recovery. + +Control pointer and key input is queued across native frames. After a drag, wait for both the +expected document state and `document.inspect.canUndo` before testing undo; an object can appear +in the preview before its undo entry is committed. On macOS, default shortcuts owned by the native +menu (including Command-Z) are skipped by the egui shortcut handler. `ui.key` does not send a Cocoa +menu event; use `ui.menu.invoke` for a menu-command test and test physical accelerators separately. + **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 @@ -44,3 +55,40 @@ merged document; the template stays as it was ([agents.md](agents.md#data-merge) Headless window screenshots (locked screen, hidden window): `cargo run -p designcraft-ui-egui --example ui_shot -- script.jsonl`, where each line is one of the requests above, `{"shot": "/abs/out.png"}` or `{"steps": n}` (renders the whole UI offscreen with wgpu). The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents. + +## 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. + +After panel customization, `app.tablePanel` (including Shift+F9) still toggles the +Table panel. `ui.set` with `closePanel` closes the last explicitly opened panel; +`dockExpanded:false` hides the root dock while floating groups remain visible. +The icon rail can reopen a collapsed dock through any root panel. Activating a +floating panel also raises its group. These operations change only UI layout. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index cebd620..9493515 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -73,7 +73,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] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"]; /// First layer allowed to use UI crates. pub const UI_MIN_LAYER: u8 = 6; @@ -285,7 +285,7 @@ mod tests { #[test] fn ui_crates_below_l6_flagged() { - for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { + for dep in ["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { let v = check(&[c("designcraft-engine", &[(dep, Normal, false)])]); assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); }