diff --git a/Cargo.lock b/Cargo.lock index f572aa69..0cdde478 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,8 +687,10 @@ dependencies = [ "cadcraft-engine", "cadcraft-geom", "cadcraft-render", + "craft-ui", "egui", "egui-wgpu", + "egui_kittest", "image", "log", "naga", @@ -825,6 +827,16 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.8" @@ -890,6 +902,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f#b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -945,6 +966,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -1131,6 +1165,27 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + [[package]] name = "either" version = "1.19.0" @@ -1443,6 +1498,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1799,6 +1863,25 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.15.0" @@ -1920,6 +2003,16 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kittest" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -1932,6 +2025,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" @@ -2529,6 +2628,16 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "orbclient" version = "0.3.55" diff --git a/Cargo.toml b/Cargo.toml index a9bc8a7a..c238654e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,9 @@ skrifa = "0.47" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # ui +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b1406e3e6a6f48a88509df6bbcdb0d07ab91cc2f", features = ["serde"] } egui = "0.36" +egui_kittest = "0.36" eframe = { version = "0.36", default-features = false } egui-wgpu = "0.36" rfd = "0.17" diff --git a/README.md b/README.md index 44cb0f93..6d642fbc 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,9 @@ numbers): ## Quick start +Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum +remains Rust 1.90. + ```sh git clone https://github.com/storytold/cadcraft cd cadcraft @@ -179,6 +182,14 @@ apps: cadcraft · cadcraft-cli · cadcraft-web Nothing below `ui-egui` knows about egui, so the front end can be replaced. `cargo xtask ci` checks formatting, clippy, tests, asset attribution, the crate layering and the wasm build. +Drafting and layer-palette icon buttons use the pinned `craft-ui` button component for interaction, +accessible labels and keyboard focus. CADCraft keeps its multicolor icon painter, dark palette, +disabled tint and tool commands. The native host saves workspace visibility and toolset choices +atomically; `CADCRAFT_CONFIG_DIR` selects an isolated profile. Compact toolbar groups move into a +More menu, and status readouts truncate with a full-value tooltip before they reach layout tabs. +Painted toolbar and tab controls +have accessible labels and visible keyboard focus. Property controls remain application-specific. + ## Roadmap See [ROADMAP.md](ROADMAP.md) for milestones, measured parity and the estimate of remaining work. @@ -306,3 +317,5 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

+ +Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. diff --git a/ROADMAP.md b/ROADMAP.md index f975cdd6..6fcd4f89 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,7 +82,17 @@ about 150–190 hours of elapsed time), CADCraft would reach broad AutoCAD parit ## Current focus +The drafting and layer-palette icon-button helper uses shared `craft-ui` interaction and accessibility, +with CADCraft's original multicolor painting and disabled appearance. This does not change drafting +commands. The native host now restores workspace visibility, toolset choices and collapsed groups; +malformed preference files are reported and preserved. Painted toolbar and document/toolset/layout +tabs expose keyboard focus and labels, and the control protocol can observe Tab/Shift-Tab traversal. +Compact windows keep toolbar command groups accessible through a More menu, while coordinates and +transient messages share an ellipsized readout that does not cover layout names. + 1. Finish the alpha checklist above (release builds, AutoCAD open check, smoke test of every command, autosave/recovery, plot preview). 2. Layer filter groups, constraint bar polish (close buttons, hover highlight). 3. Native MULTILEADER objects in DXF; stop `*D` block duplication on re-save. 4. Then 3D foundations (UCS, orbit, solids via truck/csgrs per plan/adr/0001). + +- **2026-10-10 (UI docking):** Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. No milestone percentage change. diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 661bba7c..88e2446f 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -705,7 +705,6 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - fn args(values: &[&str]) -> Vec { values.iter().map(|value| (*value).into()).collect() } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index bdee8ecf..edb3832f 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -16,37 +16,64 @@ mod control_server; mod graphics; #[cfg(target_os = "macos")] mod native_menu; +mod workspace; use cadcraft_engine::Session; use cadcraft_ui_egui::{CadApp, Services}; -struct App(CadApp, #[cfg(target_os = "macos")] Option); +struct App { + cad: CadApp, + workspace: workspace::Workspace, + workspace_error: Option, + #[cfg(target_os = "macos")] + native_menu: Option, +} + +impl App { + fn persist_workspace(&mut self, final_attempt: bool) { + let result = if final_attempt { self.workspace.save_on_exit(&self.cad.ui) } else { self.workspace.save_if_changed(&self.cad.ui) }; + if let Err(error) = result { + if self.workspace_error.as_ref() != Some(&error) { + eprintln!("CADCraft workspace: {error}"); + self.cad.set_status(format!("Workspace could not be saved: {error}")); + self.workspace_error = Some(error); + } + } else { + self.workspace_error = None; + } + } +} impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] { - if self.1.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() { - self.1 = Some(native_menu::NativeMenu::install(&mut self.0)); + if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() { + self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad)); } - if let Some(m) = &mut self.1 { - m.poll(&mut self.0, ctx); + if let Some(m) = &mut self.native_menu { + m.poll(&mut self.cad, ctx); } } - self.0.logic(ctx); - if self.0.quit_requested { + self.cad.logic(ctx); + self.persist_workspace(false); + if self.cad.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.cad.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.cad.ui(ui); + self.persist_workspace(false); + } + fn on_exit(&mut self) { + self.persist_workspace(true); } /// Preferences (the interface theme) survive restarts; window and egui state are not kept. fn save(&mut self, storage: &mut dyn eframe::Storage) { - storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json()); + storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json()); } fn persist_egui_memory(&self) -> bool { false @@ -144,6 +171,14 @@ fn main() -> eframe::Result { options, Box::new(move |cc| { let mut app = CadApp::new(Session::empty(), services()); + let (workspace, restored, workspace_error) = workspace::Workspace::load(); + if let Some(ui) = restored { + app.ui = ui; + } + if let Some(error) = &workspace_error { + eprintln!("CADCraft workspace: {error}"); + app.set_status(error); + } if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) { app.load_prefs(&prefs); } @@ -167,11 +202,13 @@ fn main() -> eframe::Result { if app.session.docs.is_empty() { app.session.new_drawing(false); } - Ok(Box::new(App( - app, + Ok(Box::new(App { + cad: app, + workspace, + workspace_error, #[cfg(target_os = "macos")] - None, - ))) + native_menu: None, + })) }), ) } diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs new file mode 100644 index 00000000..9526eb9c --- /dev/null +++ b/apps/cadcraft/src/workspace.rs @@ -0,0 +1,301 @@ +//! Native workspace preferences. Drawing data and transient dialogs never enter this file. + +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use cadcraft_ui_egui::UiState; +use serde_json::{Value, json}; + +const MAX_BYTES: u64 = 64 * 1024; +static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +pub struct Workspace { + path: Option, + last_attempt: Option, + read_failed: bool, + pending_write_error: Option, +} + +fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Option { + if let Some(root) = get("CADCRAFT_CONFIG_DIR") { + return (!root.is_empty()).then(|| PathBuf::from(root)); + } + let nonempty = |key| get(key).filter(|value| !value.is_empty()).map(PathBuf::from); + match platform { + "macos" => nonempty("HOME").map(|home| home.join("Library/Application Support/CADCraft")), + "windows" => nonempty("APPDATA").map(|root| root.join("CADCraft")), + _ => nonempty("XDG_CONFIG_HOME").map(|root| root.join("cadcraft")).or_else(|| nonempty("HOME").map(|home| home.join(".config/cadcraft"))), + } +} + +fn snapshot(ui: &UiState) -> Result { + ui.docking.validate()?; + if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") { + return Err("toolsetTab must be Drafting or Modeling".into()); + } + if ui.collapsed_groups.len() > 64 || ui.collapsed_groups.iter().any(|name| name.len() > 256) { + return Err("collapsedGroups exceeds the workspace size limit".into()); + } + if ui.history_lines > 12 { + return Err("historyLines must be between 0 and 12".into()); + } + Ok(json!({ + "docking": ui.docking, + "showToolsets": ui.show_toolsets, + "showPalettes": ui.show_palettes, + "showToolbar": ui.show_toolbar, + "showFileTabs": ui.show_file_tabs, + "showStatusBar": ui.show_status_bar, + "showCommandLine": ui.show_command_line, + "showViewcube": ui.show_viewcube, + "showUcsIcon": ui.show_ucs_icon, + "showLayerList": ui.show_layer_list, + "toolsetTab": ui.toolset_tab, + "collapsedGroups": ui.collapsed_groups, + "propertiesAll": ui.properties_all, + "inWindowMenu": ui.in_window_menu, + "historyLines": ui.history_lines, + })) +} + +fn read(path: &Path) -> Result, String> { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("{}: {error}", path.display())), + }; + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("{}: {error}", path.display()))?; + if bytes.len() as u64 > MAX_BYTES { + return Err(format!("{}: workspace file exceeds 64 KiB", path.display())); + } + let value: Value = serde_json::from_slice(&bytes).map_err(|error| format!("{}: invalid workspace JSON: {error}", path.display()))?; + if value.get("version").and_then(Value::as_u64) != Some(1) { + return Err(format!("{}: unsupported workspace version", path.display())); + } + let workspace = + value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?; + let mut ui: UiState = serde_json::from_value(workspace.clone()).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?; + // Old or hand-edited files cannot reopen a dialog or replace the active drawing view. + ui.dialog = None; + ui.start_tab = false; + snapshot(&ui).map_err(|error| format!("{}: {error}", path.display()))?; + Ok(Some(ui)) +} + +fn write(path: &Path, workspace: &Value) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(&json!({"version": 1, "workspace": workspace})).map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_BYTES { + return Err("workspace exceeds 64 KiB".into()); + } + let parent = path.parent().ok_or("workspace path has no parent")?; + fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?; + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let temporary = parent.join(format!(".workspace-{}-{sequence}.tmp", std::process::id())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temporary).map_err(|error| format!("{}: {error}", temporary.display()))?; + let result = (|| { + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, path) + })(); + if let Err(error) = result { + let _ = fs::remove_file(&temporary); + return Err(format!("{}: could not save workspace: {error}", path.display())); + } + Ok(()) +} + +impl Workspace { + pub fn load() -> (Self, Option, Option) { + Self::at(config_dir_from(std::env::consts::OS, |key| std::env::var_os(key)).map(|root| root.join("workspace.json"))) + } + + fn at(path: Option) -> (Self, Option, Option) { + let result = path.as_deref().map(read).transpose().map(Option::flatten); + match result { + Ok(ui) => { + let initial = ui.as_ref().cloned().unwrap_or_default(); + (Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None }, ui, None) + } + Err(error) => ( + Self { path, last_attempt: None, read_failed: true, pending_write_error: None }, + None, + Some(format!("Workspace could not be restored; its file was left unchanged. {error}")), + ), + } + } + + pub fn save_if_changed(&mut self, ui: &UiState) -> Result<(), String> { + let Some(path) = &self.path else { return Ok(()) }; + if self.read_failed { + // Preserve a corrupt, oversized or newer-version file until the user repairs it. + return Ok(()); + } + let value = snapshot(ui)?; + if self.last_attempt.as_ref() == Some(&value) { + return self.pending_write_error.clone().map_or(Ok(()), Err); + } + // Keep a failed state's warning without retrying disk I/O every rendered frame. + self.last_attempt = Some(value.clone()); + let result = write(path, &value); + self.pending_write_error = result.as_ref().err().cloned(); + result + } + + pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> { + if self.pending_write_error.is_some() { + self.last_attempt = None; + } + self.save_if_changed(ui) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir() -> PathBuf { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("cadcraft-workspace-test-{}-{sequence}", std::process::id())); + fs::create_dir_all(&path).unwrap(); + path + } + + #[test] + fn profile_override_isolated_or_explicitly_disabled() { + let get = |key: &str| match key { + "CADCRAFT_CONFIG_DIR" => Some(OsString::from("/private/test-state")), + "HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")), + _ => None, + }; + for platform in ["macos", "windows", "linux", "freebsd"] { + assert_eq!(config_dir_from(platform, get), Some(PathBuf::from("/private/test-state"))); + assert_eq!(config_dir_from(platform, |key| if key == "CADCRAFT_CONFIG_DIR" { Some(OsString::new()) } else { get(key) }), None); + } + assert_eq!( + config_dir_from("linux", |key| (key == "XDG_CONFIG_HOME").then(|| OsString::from("/config"))), + Some(PathBuf::from("/config/cadcraft")) + ); + assert_eq!(config_dir_from("macos", |_| None), None); + } + + #[test] + fn workspace_survives_new_store_without_transient_or_document_state() { + let directory = temp_dir(); + let path = directory.join("workspace.json"); + let (mut first, restored, error) = Workspace::at(Some(path.clone())); + assert!(restored.is_none() && error.is_none()); + let ui = UiState { + show_palettes: false, + show_command_line: false, + toolset_tab: "Modeling".into(), + collapsed_groups: vec!["Solid".into()], + history_lines: 5, + dialog: Some("layers".into()), + start_tab: true, + ..Default::default() + }; + first.save_if_changed(&ui).unwrap(); + let bytes = fs::read(&path).unwrap(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert!(value["workspace"].get("dialog").is_none()); + assert!(value["workspace"].get("startTab").is_none()); + let (_, restored, error) = Workspace::at(Some(path.clone())); + assert!(error.is_none()); + let restored = restored.unwrap(); + assert_eq!(snapshot(&restored).unwrap(), snapshot(&ui).unwrap()); + assert!(restored.dialog.is_none() && !restored.start_tab); + let mut transient_only = ui.clone(); + transient_only.dialog = None; + transient_only.start_tab = false; + first.save_if_changed(&transient_only).unwrap(); + assert_eq!(fs::read(&path).unwrap(), bytes); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn docked_floating_and_hidden_panels_survive_native_workspace_file_reload() { + use cadcraft_ui_egui::docking::Panel; + let directory = temp_dir(); + let path = directory.join("workspace.json"); + let (mut store, _, _) = Workspace::at(Some(path.clone())); + let mut ui = UiState::default(); + let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default()); + app.run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[120,90,340,410]})).unwrap(); + ui.docking = app.ui.docking; + ui.docking.set_visible(Panel::ToolSets, false).unwrap(); + ui.show_toolsets = false; + store.save_if_changed(&ui).unwrap(); + let (_, reloaded, error) = Workspace::at(Some(path)); + assert!(error.is_none()); + let mut reloaded = reloaded.unwrap(); + assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap()); + reloaded.docking.set_visible(Panel::ToolSets, true).unwrap(); + assert!(reloaded.docking.layout.contains(&Panel::ToolSets)); + assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn malformed_oversized_and_newer_state_are_reported_and_preserved() { + let directory = temp_dir(); + let path = directory.join("workspace.json"); + for bytes in [ + b"{invalid".to_vec(), + vec![b' '; MAX_BYTES as usize + 1], + br#"{"version":2,"workspace":{}}"#.to_vec(), + br#"{"version":1,"workspace":{"toolsetTab":"unknown"}}"#.to_vec(), + br#"{"version":1,"workspace":{"historyLines":18446744073709551615}}"#.to_vec(), + ] { + fs::write(&path, &bytes).unwrap(); + let (mut store, ui, error) = Workspace::at(Some(path.clone())); + assert!(ui.is_none()); + assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged"))); + store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }).unwrap(); + assert_eq!(fs::read(&path).unwrap(), bytes); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn invalid_update_cannot_replace_last_good_workspace() { + let directory = temp_dir(); + let path = directory.join("workspace.json"); + let (mut store, _, _) = Workspace::at(Some(path.clone())); + let mut ui = UiState { show_palettes: false, ..Default::default() }; + store.save_if_changed(&ui).unwrap(); + let good = fs::read(&path).unwrap(); + ui.collapsed_groups = vec!["x".repeat(257)]; + assert!(store.save_if_changed(&ui).is_err()); + assert_eq!(fs::read(&path).unwrap(), good); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn failed_write_keeps_warning_and_retries_once_on_exit() { + let directory = temp_dir(); + let path = directory.join("workspace.json"); + let (mut store, _, _) = Workspace::at(Some(path.clone())); + // Simulate a destination becoming unavailable after startup. + fs::create_dir(&path).unwrap(); + let ui = UiState { show_palettes: false, ..Default::default() }; + let first_error = store.save_if_changed(&ui).unwrap_err(); + fs::remove_dir(&path).unwrap(); + assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error); + assert!(!path.exists(), "ordinary frames must not retry disk writes"); + store.save_on_exit(&ui).unwrap(); + assert!(!read(&path).unwrap().unwrap().show_palettes); + fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/crates/engine/src/assoc.rs b/crates/engine/src/assoc.rs index a48d2a35..91d901e2 100644 --- a/crates/engine/src/assoc.rs +++ b/crates/engine/src/assoc.rs @@ -228,8 +228,7 @@ pub fn recompute(dm: &Dimension, prev: Option<&Dimension>, before: &Drawing, aft // An ordinate's `defpt` is its fixed datum (the drawing origin); it must not follow the feature. let ordinate = matches!(nd.kind, DimKind::Ordinate { .. }); for name in POINTS { - if !moved.iter().any(|m| m == name) - && !(ordinate && name == "defpt") + if !(moved.iter().any(|m| m == name) || ordinate && name == "defpt") && !(linear && (name == "p13" || name == "p14")) && let Some(slot) = def_point_mut(&mut nd, name) { diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index a2e2c0a1..e9550ca0 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -199,9 +199,8 @@ fn run_area(s: &mut Session, p: &Value) -> Result { let mut total = (0.0, 0.0); for (i, g) in geoms.iter().enumerate() { let probe = polys.get(i).and_then(|p| p.first().copied()); - let depth = probe.map_or(0, |pt| { - polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count() - }); + let depth = + probe.map_or(0, |pt| polys.iter().enumerate().filter(|(j, q)| *j != i && cadcraft_geom::point_in_polygon(q, pt)).count()); total.0 += if depth % 2 == 0 { g.area().abs() } else { -g.area().abs() }; total.1 += g.len(); } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 9f11318d..b560c4c3 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1265,11 +1265,8 @@ fn attdef_prompt_survives_dxf_roundtrip() { d.add(&Space::Model, Default::default(), attdef("Enter value")).unwrap(); d.add(&Space::Model, Default::default(), attdef("")).unwrap(); let back = roundtrip(&d); - let prompts: Vec<&str> = back - .model - .iter() - .filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.prompt.as_str()) } else { None }) - .collect(); + let prompts: Vec<&str> = + back.model.iter().filter_map(|e| if let EntityKind::AttDef(a) = &e.kind { Some(a.prompt.as_str()) } else { None }).collect(); assert_eq!(prompts, vec!["Enter value", ""]); } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ddf9e334..16a8d1c0 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -4,11 +4,13 @@ description = "CADCraft user interface (egui): the swappable front end" version.workspace = true edition.workspace = true license.workspace = true +rust-version = "1.95" [lints] workspace = true [dependencies] +craft-ui = { workspace = true } cadcraft-geom = { workspace = true } cadcraft-color = { workspace = true } cadcraft-doc = { workspace = true } @@ -23,6 +25,7 @@ log = { workspace = true } image = { workspace = true } [dev-dependencies] +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } # Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses). naga = { version = "30", features = ["wgsl-in", "glsl-out"] } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56be5664..e0fa42cc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -538,7 +538,13 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let _ = app.session.zoom_about(f64::from(zoom_pinch), xf.to_world(hp)); } // Pan with the middle button (or Shift+right-drag / two-finger drag on trackpads is scroll). - if middle_down && let Some(hp) = hover_pos { + let middle_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Middle)); + let focused = ui.input(|i| i.focused); + if middle_down + && focused + && (app.canvas.pan_last.is_some() || (middle_pressed && inside)) + && let Some(hp) = hover_pos + { if let Some(last) = app.canvas.pan_last { let d = hp - last; if let Ok(st) = app.session.state_mut() { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index aa761327..cf9632cb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -54,7 +54,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { ); // Allow dragging the window by the title row. let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h)); - let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::click_and_drag()); + let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::CLICK | Sense::DRAG); if resp.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -68,7 +68,31 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { let mut x = r.left() + if app.integrated_titlebar { 120.0 } else { 12.0 }; let size = 26.0; let mut clicked = None; - for group in TOOLBAR { + let widths: Vec<_> = TOOLBAR.iter().map(|group| group.len() as f32 * (size + 6.0) - 6.0).collect(); + let available = (row.right() - 12.0 - x).max(0.0); + let Some(fit) = craft_ui::layout::fit_toolbar(&widths, available, 24.0, 30.0) else { + // Very small windows still expose the complete command row by scrolling. + ui.scope_builder(egui::UiBuilder::new().max_rect(row.shrink2(vec2(4.0, 0.0))), |ui| { + egui::ScrollArea::horizontal().id_salt("toolbar_small").show(ui, |ui| { + ui.horizontal(|ui| { + for group in TOOLBAR { + for (icon, cmd, tip) in *group { + if icons::button(ui, *icon, size, tip, false).clicked() { + clicked = Some(*cmd); + } + } + ui.add_space(18.0); + } + }); + }); + }); + if let Some(command) = clicked { + app.start(command); + } + return; + }; + for (index, _) in &fit.shown { + let Some(group) = TOOLBAR.get(*index) else { continue }; for (icon, cmd, tip) in group.iter() { let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(size, size)); let resp = ui.interact(br, ui.id().with(("tb", *cmd)), Sense::click()); @@ -76,6 +100,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6)); } icons::paint(ui.painter(), br.shrink(4.0), *icon, false); + icons::describe_control(ui, &resp, tip, None, false); if resp.on_hover_text(*tip).clicked() { clicked = Some(*cmd); } @@ -83,6 +108,34 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { } x += 18.0; } + if !fit.overflow.is_empty() { + let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(fit.overflow_width, size)); + let response = ui.interact(br, ui.id().with("toolbar_more"), Sense::click()); + if response.hovered() { + ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6)); + } + for offset in [-5.0, 0.0, 5.0] { + ui.painter().circle_filled(br.center() + vec2(offset, 0.0), 1.5, t.text); + } + icons::describe_control(ui, &response, "More toolbar commands", None, false); + egui::Popup::menu(&response).show(|ui| { + for (position, index) in fit.overflow.iter().enumerate() { + let Some(group) = TOOLBAR.get(*index) else { continue }; + if position > 0 { + ui.separator(); + } + for (_, command, label) in *group { + let response = ui.button(*label); + crate::control::record_widget(ui, &response, label, "button", None); + if response.clicked() { + clicked = Some(*command); + ui.close(); + } + } + } + }); + response.on_hover_text("More toolbar commands"); + } if let Some(c) = clicked { app.start(c); } @@ -102,12 +155,13 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click()); icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false); + icons::describe_control(ui, &presp, "New Drawing", None, false); if presp.on_hover_text("New Drawing").clicked() { app.start("new"); app.ui.start_tab = false; } x += 24.0; - let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, w: f32, id: egui::Id| -> (bool, bool) { + let mut tab = |ui: &mut egui::Ui, label: &str, active: bool, closable: bool, w: f32, id: egui::Id| -> (bool, bool) { let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, r.height())); let resp = ui.interact(tr, id, Sense::click()); ui.painter().rect_filled( @@ -129,17 +183,19 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { if active { t.text } else { t.text_dim }, ); let mut close = false; - if active || resp.hovered() { + if closable && (active || resp.hovered()) { let cr = Rect::from_center_size(pos2(tr.right() - 11.0, tr.center().y), vec2(12.0, 12.0)); let cresp = ui.interact(cr, id.with("x"), Sense::click()); icons::paint(ui.painter(), cr, Icon::Close, false); + icons::describe_control(ui, &cresp, &format!("Close {label}"), None, false); close = cresp.clicked(); } + icons::describe_control(ui, &resp, label, Some(active), true); ui.painter().vline(tr.right(), tr.y_range(), Stroke::new(1.0, t.border)); x += w; (resp.clicked(), close) }; - let (c, _) = tab(ui, "Start", app.ui.start_tab || app.session.docs.is_empty(), 110.0, ui.id().with("tab_start")); + let (c, _) = tab(ui, "Start", app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start")); if c { app.ui.start_tab = true; } @@ -153,7 +209,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { .map(|(i, d)| (i, format!("{}{}", d.title, if d.is_dirty() { "*" } else { "" }), i == app.session.active)) .collect(); for (i, title, active) in titles { - let (c, x) = tab(ui, &title, active && !app.ui.start_tab, 170.0, ui.id().with(("tab", i))); + let (c, x) = tab(ui, &title, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i))); if c { switch_to = Some(i); } @@ -189,6 +245,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click()); icons::paint(&p, br.shrink(2.0), icon, false); + icons::describe_control(ui, &resp, tip, None, false); if resp.on_hover_text(tip).clicked() && icon == Icon::Plus { let _ = app.run("layout.new", json!({})); } @@ -219,6 +276,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { p.rect_filled(tr, 2.0, t.control.gamma_multiply(0.5)); } p.galley(pos2(tr.left() + 13.0, tr.center().y - g.size().y / 2.0), g, if active { t.text } else { t.text_dim }); + icons::describe_control(ui, &resp, name, Some(active), true); if resp.clicked() { switch = Some(name.clone()); } @@ -227,6 +285,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click()); icons::paint(&p, pr.shrink(2.0), Icon::Plus, false); + icons::describe_control(ui, &presp, "New layout", None, false); if presp.on_hover_text("New layout").clicked() && let Ok(v) = app.run("layout.new", json!({})) && let Some(n) = v.get("name").and_then(serde_json::Value::as_str) @@ -260,7 +319,6 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { ]; let size = 20.0; let mut rx = r.right() - 8.0 - toggles.len() as f32 * (size + 4.0); - let coords_w = 210.0; // Coordinates. let coord = app.canvas.cursor.map(|c| { let (lu, lp) = app.session.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4)); @@ -280,16 +338,33 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click()); p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) }); p.text(br.center(), egui::Align2::CENTER_CENTER, label, crate::theme::small(), t.text); + icons::describe_control(ui, &resp, label, None, false); if resp.on_hover_text("Switch between model space in a viewport and paper space").clicked() { let _ = app.run(if model { "pspace" } else { "mspace" }, json!({})); app.canvas.list = None; } coord_right = br.left() - 10.0; } - if let Some(c) = coord { - p.text(pos2(coord_right, r.center().y), egui::Align2::RIGHT_CENTER, c, crate::theme::body(), t.text); + // A transient message occupies the same bounded readout slot as coordinates. + // Ellipsis and a tooltip keep either value readable without covering layout tabs. + let message = app.status.as_ref().filter(|(_, at)| crate::now_ms() - at < 5000.0).map(|(message, _)| message); + let readout = message.cloned().or(coord); + if let Some(text) = readout { + let left = x + 8.0; + let width = (coord_right - left).max(0.0); + if width > 4.0 { + let color = if message.is_some() { t.warn } else { t.text }; + let mut job = egui::text::LayoutJob::simple_singleline(text.clone(), crate::theme::body(), color); + job.wrap.max_width = width; + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + let galley = p.layout_job(job); + let rect = Rect::from_min_max(pos2(left, r.top()), pos2(coord_right, r.bottom())); + let text_x = if message.is_some() { left } else { coord_right - galley.size().x }; + p.with_clip_rect(rect).galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, color); + ui.interact(rect, ui.id().with("status_readout"), Sense::hover()).on_hover_text(text); + } } - let _ = coords_w; let mut toggle_cmd = None; for (icon, on, cmd, tip) in toggles { let br = Rect::from_min_size(pos2(rx, r.center().y - size / 2.0), vec2(size, size)); @@ -304,6 +379,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { // Re-tint the icon blue-ish by an underline. p.hline(br.x_range().shrink(4.0), br.bottom() - 1.0, Stroke::new(1.5, t.toggle_on)); } + icons::describe_control(ui, &resp, tip, Some(on), false); if resp.on_hover_text(tip).clicked() { toggle_cmd = Some(cmd); } @@ -312,12 +388,6 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { if let Some(c) = toggle_cmd { app.start(c); } - // Transient status message. - if let Some((msg, at)) = &app.status - && crate::now_ms() - at < 5000.0 - { - p.text(pos2(x + 20.0, r.center().y), egui::Align2::LEFT_CENTER, msg, crate::theme::small(), t.warn); - } }); } diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index c66798a6..efb8f5ef 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -207,16 +207,21 @@ pub fn submit(app: &mut CadApp) { app.cmdline(&text); } +fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize { + let available = ((bar.top() - canvas.top() - 8.0).max(0.0) / LINE_H) as usize; + app.ui.history_lines.min(MAX_LINES).min(available) +} + pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let t = Tokens::get(); keyboard(app, ui.ctx()); - let w = (canvas.width() * 0.48).clamp(360.0, 760.0); + let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0)); let h = 24.0; let bar = Rect::from_min_size(pos2(canvas.center().x - w / 2.0, canvas.bottom() - h - 10.0), vec2(w, h)); let p = ui.painter_at(canvas); history(app, ui, canvas, bar); // History lines above the bar (the expanded window replaces them). - let n = if app.cmd.expanded { 0 } else { app.ui.history_lines.min(MAX_LINES) }; + let n = if app.cmd.expanded { 0 } else { visible_lines(app, canvas, bar) }; let lines: Vec = app.session.log.iter().rev().take(n).rev().cloned().collect(); if !lines.is_empty() { let lh = 16.0; @@ -324,7 +329,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { bar.left() + 24.0, bar.top() - 6.0 - rh * sug.len() as f32 - (if lines.is_empty() { 0.0 } else { 16.0 * lines.len() as f32 + 6.0 }), ), - vec2(300.0, rh * sug.len() as f32), + vec2((bar.width() - 24.0).max(0.0), rh * sug.len() as f32), ); p.rect_filled(lr, 3.0, t.list_bg); p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); @@ -359,7 +364,7 @@ struct Zones { } fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones { - let n = if app.cmd.expanded { 0 } else { app.ui.history_lines.min(MAX_LINES).min(app.session.log.len()) }; + let n = if app.cmd.expanded { 0 } else { visible_lines(app, canvas, bar).min(app.session.log.len()) }; let lines = (n > 0).then(|| Rect::from_min_size(pos2(bar.left(), bar.top() - LINE_H * n as f32 - 4.0), vec2(bar.width(), LINE_H * n as f32 + 2.0))); let window = app.cmd.expanded.then(|| { @@ -400,7 +405,7 @@ fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) { && let Some(o) = ui.input(|i| i.pointer.press_origin()) && on_grip(o) { - app.cmd.resize_from = Some((o.y, app.ui.history_lines.min(MAX_LINES))); + app.cmd.resize_from = Some((o.y, visible_lines(app, canvas, bar))); } if let Some((y0, n0)) = app.cmd.resize_from { if let (true, Some(p)) = (resp.dragged(), resp.interact_pointer_pos()) { @@ -449,6 +454,24 @@ mod tests { use cadcraft_engine::Session; use egui::{Event, PointerButton}; + #[test] + fn compact_canvas_contains_history_controls_without_changing_line_preference() { + let mut app = CadApp::new(Session::empty(), crate::Services::default()); + app.ui.history_lines = 12; + for i in 0..20 { + app.session.echo(format!("line {i}")); + } + let canvas = Rect::from_min_size(Pos2::ZERO, vec2(300.0, 180.0)); + let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0)); + let bar = Rect::from_min_size(pos2(canvas.center().x - w / 2.0, canvas.bottom() - 34.0), vec2(w, 24.0)); + let z = zones(&app, canvas, bar); + assert!(canvas.contains_rect(bar)); + assert!(canvas.contains_rect(z.chevron)); + assert!(z.lines.is_some_and(|r| canvas.contains_rect(r))); + assert!(canvas.contains_rect(z.grip)); + assert_eq!(app.ui.history_lines, 12); + } + fn frame(app: &mut CadApp, ctx: &egui::Context, events: Vec) { let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { @@ -473,7 +496,7 @@ mod tests { frame(&mut app, &ctx, Vec::new()); frame(&mut app, &ctx, Vec::new()); let canvas = app.canvas.rect.unwrap_or(Rect::NOTHING); - let w = (canvas.width() * 0.48).clamp(360.0, 760.0); + let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0)); let bar = Rect::from_min_size(pos2(canvas.center().x - w / 2.0, canvas.bottom() - 34.0), vec2(w, 24.0)); let z = zones(&app, canvas, bar); let Some(lines) = z.lines else { panic!("three history lines show by default") }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 5432e095..b6b0eaac 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -9,6 +9,7 @@ //! - `engine.commands`: every command with menu path, aliases, params and enablement //! - `drawing.inspect {entities?, limit?}`: drawing summary and entities (verifies agent work) //! - `ui.inspect`: UI state, canvas rect, view, performance +//! - `ui.elements {label?, role?, focused?}`: observed visible controls and actual egui focus //! - `ui.menu.list`: the menu tree; `ui.menu.invoke {command}`: like choosing a menu item //! - `ui.pointer {x, y, space?: "world"|"screen", button?: left|right, action?: click|move}` //! - `ui.click {x, y}`, `ui.move {x, y}`: real egui pointer input in screen points @@ -46,6 +47,88 @@ pub enum Outcome { Screenshot { path: Option }, } +#[derive(Clone)] +struct Widget { + id: egui::Id, + label: String, + role: &'static str, + rect: [f32; 4], + enabled: bool, + visible: bool, + selected: Option, +} + +fn widgets_id() -> egui::Id { + egui::Id::new("cadcraft-observed-widgets") +} + +pub(crate) fn begin_frame(ctx: &egui::Context) { + ctx.data_mut(|data| data.remove::>(widgets_id())); +} + +/// Observe a real response. This never assigns focus or changes input routing. +pub(crate) fn record_widget(ui: &egui::Ui, response: &egui::Response, label: &str, role: &'static str, selected: Option) { + if !response.rect.is_finite() || !response.rect.is_positive() { + return; + } + let widget = Widget { + id: response.id, + label: label.chars().take(512).collect(), + role, + rect: [response.rect.left(), response.rect.top(), response.rect.width(), response.rect.height()], + enabled: response.enabled(), + visible: ui.is_rect_visible(response.rect), + selected, + }; + ui.ctx().data_mut(|data| { + let widgets = data.get_temp_mut_or_default::>(widgets_id()); + if let Some(previous) = widgets.iter_mut().find(|previous| previous.id == widget.id) { + *previous = widget; + } else if widgets.len() < 2048 { + widgets.push(widget); + } + }); +} + +fn widgets(ctx: &egui::Context) -> Vec { + ctx.data_mut(|data| data.get_temp::>(widgets_id()).unwrap_or_default()) +} + +fn widget_json(widget: &Widget, focused: Option) -> Value { + json!({ + "id": format!("{:016x}", widget.id.value()), "label": widget.label, "role": widget.role, + "rect": widget.rect, "enabled": widget.enabled, "visible": widget.visible, + "selected": widget.selected, "focused": focused == Some(widget.id), + }) +} + +fn focus_json(ctx: &egui::Context) -> Value { + let focused = ctx.memory(|memory| memory.focused()); + let observed = widgets(ctx); + let widget = observed.iter().find(|widget| focused == Some(widget.id)); + json!({ + "id": focused.map(|id| format!("{:016x}", id.value())), + "label": widget.map(|widget| &widget.label), + "role": widget.map(|widget| widget.role), + "keyboardInput": ctx.egui_wants_keyboard_input(), + }) +} + +fn elements(ctx: &egui::Context, params: &Value) -> Value { + let focused = ctx.memory(|memory| memory.focused()); + Value::Array( + widgets(ctx) + .iter() + .filter(|widget| { + params.get("label").and_then(Value::as_str).is_none_or(|label| widget.label == label) + && params.get("role").and_then(Value::as_str).is_none_or(|role| widget.role == role) + && params.get("focused").and_then(Value::as_bool).is_none_or(|value| value == (focused == Some(widget.id))) + }) + .map(|widget| widget_json(widget, focused)) + .collect(), + ) +} + fn ok(v: Value) -> Outcome { Outcome::Done(json!({"ok": true, "result": v})) } @@ -95,6 +178,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value { json!({ "ui": serde_json::to_value(&app.ui).unwrap_or_default(), "theme": if app.shown_theme() == egui::Theme::Light { "light" } else { "dark" }, + "focus": focus_json(ctx), "canvasRect": app.canvas.rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])), "window": [r.width(), r.height()], "view": view.map(|v| json!({"center": [v.center.x, v.center.y], "height": v.height})), @@ -167,6 +251,11 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou "engine.commands" => ok(all_commands(app)), "drawing.inspect" | "document.inspect" => wrap(app.session.execute("drawing.inspect", p).map_err(|e| e.to_string())), "ui.inspect" => ok(inspect(app, ctx)), + "ui.elements" => ok(elements(ctx, p)), + "ui.focus" => { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ok(Value::Null) + } "ui.menu.list" => ok(Value::Array( crate::menus::tree(app).iter().map(|(m, es)| json!({"label": m, "children": es.iter().map(menu_json).collect::>()})).collect(), )), @@ -261,6 +350,9 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou } match serde_json::from_value::(cur) { Ok(u) => { + if let Err(error) = u.docking.validate() { + return err(error); + } app.ui = u; ok(serde_json::to_value(&app.ui).unwrap_or_default()) } diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs new file mode 100644 index 00000000..1b4dcd70 --- /dev/null +++ b/crates/ui-egui/src/docking.rs @@ -0,0 +1,384 @@ +//! Application panel identities and persistence around the shared docking renderer. +//! Document contents remain owned by their existing view implementations. + +use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::layout::{SplitAxis, SplitSize}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Panel { + Canvas, + ToolSets, + Layers, + Properties, +} + +impl Panel { + pub const ALL: &[Self] = &[Self::Canvas, Self::ToolSets, Self::Layers, Self::Properties]; + pub fn id(self) -> &'static str { + match self { + Self::Canvas => "canvas", + Self::ToolSets => "toolSets", + Self::Layers => "layers", + Self::Properties => "properties", + } + } + fn label(self) -> &'static str { + match self { + Self::Canvas => "Drawing", + Self::ToolSets => "Tool Sets", + Self::Layers => "Layers", + Self::Properties => "Properties", + } + } + fn default_side(self) -> Zone { + match self { + Self::Canvas => Zone::Right, + Self::ToolSets => Zone::Left, + Self::Layers => Zone::Right, + Self::Properties => Zone::Right, + } + } + fn parse(id: &str) -> Result { + Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) + } +} + +fn permissions(panel: &Panel) -> Permissions { + if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } +} + +fn tabs(panel: Panel) -> Node { + Node::Tabs { panels: vec![panel], active: 0 } +} + +fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { + Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct Workspace { + pub layout: Layout, + /// Closed panels retain their most recent group, side, size or floating rectangle. + pub hidden: Vec<(Panel, Location)>, +} + +impl Default for Workspace { + fn default() -> Self { + Self { + layout: Layout { + root: Some(split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(220.0), + tabs(Panel::ToolSets), + split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(300.0), + tabs(Panel::Canvas), + Node::Stack { + entries: vec![ + craft_ui::docking::StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, + craft_ui::docking::StackEntry { panel: Panel::Properties, open: true, height: None }, + ], + }, + ), + )), + floating: Vec::new(), + }, + hidden: Vec::new(), + } + } +} + +impl Workspace { + pub fn validate(&self) -> Result<(), String> { + self.layout.validate().map_err(|error| error.to_string())?; + if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { + return Err("The panel layout must contain its document view and bounded hidden panels".into()); + } + // Saved settings cannot turn the protected document view into a floating or closable tab. + if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { + return Err("The document view cannot float".into()); + } + let mut nodes: Vec<_> = self.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { + return Err("The document view cannot share a tab group".into()); + } + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { + return Err("The document view cannot be collapsed".into()); + } + _ => {} + } + } + let mut seen = std::collections::HashSet::new(); + for (panel, location) in &self.hidden { + if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { + return Err("A hidden panel has an invalid or duplicate identity".into()); + } + if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { + return Err("A hidden panel cannot restore over the document view".into()); + } + let mut probe = self.layout.clone(); + if let Err(error) = probe.restore(*panel, location) + && error != craft_ui::docking::DockError::MissingTarget + { + return Err(error.to_string()); + } + } + Ok(()) + } + + pub fn apply(&mut self, action: Action) -> Result<(), String> { + self.validate()?; + let mut next = self.clone(); + if let Action::Close { panel } = &action { + let location = next.layout.location(panel).map_err(|error| error.to_string())?; + next.hidden.retain(|(id, _)| id != panel); + next.hidden.push((*panel, location)); + } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { + next.hidden.retain(|(id, _)| id != panel); + } + next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; + next.validate()?; + *self = next; + Ok(()) + } + + /// Explicit opens select the requested tab and expand a collapsed stack entry. + pub fn reveal_panel(&mut self, panel: Panel) -> Result<(), String> { + let mut next = self.clone(); + next.set_visible(panel, true)?; + next.apply(Action::Activate { panel })?; + *self = next; + Ok(()) + } + + pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { + if self.layout.contains(&panel) == visible { + return Ok(()); + } + if !visible { + return self.apply(Action::Close { panel }); + } + self.validate()?; + let mut next = self.clone(); + let location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); + // Recreate the last surviving member first. Restoring a member also restores any + // hidden anchor it needs, so either close order retains the original tab order. + // Remove this member before recursion to bound malformed/cyclic saved anchors. + next.hidden.retain(|(id, _)| *id != panel); + if let Some(anchor) = location.as_ref().and_then(|saved| saved.anchor) + && !next.layout.contains(&anchor) + && next.hidden.iter().any(|(id, _)| *id == anchor) + { + next.set_visible(anchor, true)?; + } + let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); + if !restored { + next.layout + .apply_with_permissions( + Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, + permissions, + ) + .map_err(|error| error.to_string())?; + } + next.validate()?; + *self = next; + Ok(()) + } + + fn command(&mut self, params: &Value) -> Result<(), String> { + self.validate()?; + if let Some(action) = params.get("action") { + let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; + return self.apply(action); + } + let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; + if operation == "resizeSplit" { + let path: Vec = + serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; + let size: SplitSize = + serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; + return self.apply(Action::ResizeSplit { path, size }); + } + let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; + let action = match operation { + "open" => return self.reveal_panel(panel), + "close" => Action::Close { panel }, + "activate" => Action::Activate { panel }, + "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, + "resizeStack" => { + let value = params.get("height").ok_or("height is required")?; + let height = if value.is_null() { + None + } else { + Some( + value + .as_f64() + .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) + .ok_or("height must be finite and bounded")? as f32, + ) + }; + Action::ResizeStack { panel, height } + } + "float" | "moveFloating" => { + let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; + let mut values = [0.0f32; 4]; + for (output, value) in values.iter_mut().zip(rect) { + *output = value + .as_f64() + .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) + .ok_or("rect values must be finite and bounded")? as f32; + } + if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } + } + "move" => { + let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; + let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { + "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 center, left, right, top or bottom".into()), + }; + let placement = if let Some(before) = params.get("before") { + if !matches!(placement, Placement::Split(Zone::Center)) { + return Err("before only applies to a center move".into()); + } + Placement::Tab { + before: if before.is_null() { + None + } else { + Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) + }, + } + } else { + placement + }; + Action::Move { panel, anchor, placement } + } + _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), + }; + self.apply(action) + } +} + +fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> { + workspace.set_visible(Panel::ToolSets, app.ui.show_toolsets)?; + if !app.ui.show_palettes { + workspace.set_visible(Panel::Layers, false)?; + workspace.set_visible(Panel::Properties, false)?; + } else if !workspace.layout.contains(&Panel::Layers) && !workspace.layout.contains(&Panel::Properties) { + // Reopen the last surviving accordion entry before restoring its earlier sibling. + workspace.set_visible(Panel::Properties, true)?; + workspace.set_visible(Panel::Layers, true)?; + } + Ok(()) +} + +fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) { + app.ui.show_toolsets = workspace.layout.contains(&Panel::ToolSets); + app.ui.show_palettes = workspace.layout.contains(&Panel::Layers) || workspace.layout.contains(&Panel::Properties); +} + +pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result { + let mut workspace = app.ui.docking.clone(); + if reset { + workspace = Workspace::default(); + } else { + sync(&mut workspace, app)?; + workspace.command(params)?; + } + publish_visibility(app, &workspace); + app.ui.docking = workspace; + Ok(json!({"docking": app.ui.docking})) +} + +pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(); + let mut workspace = app.ui.docking.clone(); + if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) { + app.set_status(format!("Panel layout could not be restored: {error}")); + workspace = Workspace::default(); + let _ = sync(&mut workspace, app); + } + // Bodies can dispatch commands (including the drawing's typed command line). + // Keep the canonical workspace accessible while rendering an immutable snapshot. + app.ui.docking = workspace.clone(); + let mut style = DockStyle::from_ui(ui); + style.min_pane = 96.0; + style.background = t.panel; + style.tab_background = t.chrome; + style.active_background = t.tab_active; + style.text = t.text; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.font = crate::theme::body(); + let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits( + ui, + &workspace.layout, + &style, + |panel| panel.label().into(), + permissions, + panel_limits, + |ui, panel| match panel { + Panel::Canvas => { + ui.painter().rect_filled(ui.max_rect(), 0.0, t.canvas); + crate::canvas::show(app, ui); + } + Panel::ToolSets => crate::palettes::toolsets(app, ui), + Panel::Layers => { + egui::ScrollArea::vertical() + .id_salt("dock-layers") + .auto_shrink([false, false]) + .show(ui, |ui| crate::palettes::layers_section(app, ui)); + } + Panel::Properties => { + egui::ScrollArea::vertical() + .id_salt("dock-properties") + .auto_shrink([false, false]) + .show(ui, |ui| crate::palettes::properties_section(app, ui)); + } + }, + ); + // Existing panel close buttons and commands still own their visibility switches. + let rendered = workspace; + let mut workspace = app.ui.docking.clone(); + let _ = sync(&mut workspace, app); + let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; + if stale { + craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("cadcraft-docking")); + } + publish_visibility(app, &workspace); + app.ui.docking = workspace; + if stale { + return; + } + for action in output.actions { + if let Err(error) = + serde_json::to_value(action).map_err(|error| error.to_string()).and_then(|action| command(app, false, &json!({"action": action}))) + { + app.set_status(format!("Panel move was not applied: {error}")); + } + } +} + +fn panel_limits(panel: &Panel) -> PanelLimits { + let (min, max_width) = match panel { + Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0), + Panel::ToolSets => (egui::vec2(220.0, 150.0), 480.0), + Panel::Layers | Panel::Properties => (egui::vec2(220.0, 100.0), 720.0), + }; + PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1367c329..9297e5e0 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -3,6 +3,7 @@ //! Icons are line drawings on a 24×24 design grid: light strokes for existing geometry, blue for //! the geometry a command creates and orange dots for picked points. +use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2}; use crate::theme::Tokens; @@ -907,15 +908,48 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { } } -/// Small helper: an icon button that returns `true` when clicked. +/// A shared button interaction with CADCraft's original multicolor icon painting. pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: bool) -> egui::Response { let t = Tokens::get(); - let (rect, resp) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::click()); - if selected { - ui.painter().rect_filled(rect, 3.0, t.tab_active); - } else if resp.hovered() { - ui.painter().rect_filled(rect, 3.0, t.control_hover.gamma_multiply(0.6)); - } - paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled()); + let visuals = |fill| ButtonVisuals { fill, stroke: Stroke::NONE, icon: t.icon }; + let style = IconButtonStyle { + normal: visuals(Color32::TRANSPARENT), + hovered: visuals(t.control_hover.gamma_multiply(0.6)), + pressed: None, + selected: visuals(t.tab_active), + corner_radius: egui::CornerRadius::same(3), + focus_stroke: Stroke::new(1.0, t.accent), + }; + let resp = IconButton::new(tooltip, egui::vec2(size, size), &style).selected(selected).show(ui, |ui, rect, _| { + // A single tint would erase the blue geometry and orange picked points. Keep the + // original painter and its explicit dimming on the same parent-enabled UI. + paint(ui.painter(), rect.shrink(size * 0.08), icon, !ui.is_enabled()); + }); + crate::control::record_widget(ui, &resp, tooltip, "button", Some(selected)); resp.on_hover_text(tooltip) } + +/// Labels and focus outlines for controls whose existing geometry is painted by the app. +/// Pointer focus is opt-in for navigation tabs; drafting buttons keep their command-line flow. +pub fn describe_control(ui: &egui::Ui, response: &egui::Response, label: &str, selected: Option, tab: bool) { + response.widget_info(|| match selected { + Some(selected) => egui::WidgetInfo::selected(egui::WidgetType::Button, response.enabled(), selected, label), + None => egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), label), + }); + if tab { + ui.ctx().accesskit_node_builder(response.id, |node| { + node.set_role(egui::accesskit::Role::Tab); + node.clear_toggled(); + if let Some(selected) = selected { + node.set_selected(selected); + } + }); + if response.clicked() { + response.request_focus(); + } + } + if response.has_focus() && response.rect.width() > 4.0 && response.rect.height() > 4.0 { + ui.painter().rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.0, Tokens::get().accent), egui::StrokeKind::Inside); + } + crate::control::record_widget(ui, response, label, if tab { "tab" } else { "button" }, selected); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59cc389e..cf234810 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod cmdline; pub mod control; pub mod credits; pub mod dialogs; +pub mod docking; pub mod gpu; pub mod icons; pub mod layers; @@ -41,6 +42,7 @@ pub const PREFS_KEY: &str = "cadcraft.prefs"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + pub docking: docking::Workspace, pub show_toolsets: bool, pub show_palettes: bool, pub show_toolbar: bool, @@ -66,6 +68,7 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + docking: docking::Workspace::default(), show_toolsets: true, show_palettes: true, show_toolbar: true, @@ -283,6 +286,7 @@ impl CadApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); + control::begin_frame(ui.ctx()); let t = theme::Tokens::get(); chrome::title_and_toolbar(self, ui); if self.ui.in_window_menu { @@ -295,15 +299,9 @@ impl CadApp { chrome::file_tabs(self, ui); } let has_doc = !self.session.docs.is_empty() && !self.ui.start_tab; - if has_doc && self.ui.show_toolsets { - palettes::toolsets(self, ui); - } - if has_doc && self.ui.show_palettes { - palettes::right_palettes(self, ui); - } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| { if has_doc { - canvas::show(self, ui); + docking::show(self, ui); } else { chrome::start_page(self, ui); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 540d633d..c778dba3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -10,11 +10,14 @@ pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools" /// UI-only commands: (id, label, menu path, shortcut). pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ + ("ui.dock", "Arrange Panel", &[], None), + ("ui.dock.reset", "Reset Panel Layout", &["Window", "Reset Panel Layout"], None), ("ui.open", "Open...", &[], Some("Cmd+O")), ("ui.saveas", "Save As...", &[], None), ("ui.sample", "Open Sample Drawing", &["Help", "Open Sample Drawing"], None), ("ui.toggle.toolsets", "Tool Sets", &["Window", "Tool Sets"], Some("Cmd+3")), - ("ui.toggle.palettes", "Properties Inspector", &["Window", "Properties Inspector"], Some("Cmd+1")), + ("ui.panel.properties", "Properties Inspector", &["Window", "Properties Inspector"], Some("Cmd+1")), + ("ui.panel.layers", "Layers Palette", &["Window", "Layers Palette"], None), ("ui.toggle.toolbar", "Tool Bar", &["Window", "Tool Bar"], None), ("ui.toggle.filetabs", "File Tab", &["Window", "File Tab"], None), ("ui.toggle.statusbar", "Status Bar", &["Window", "Status Bar"], None), @@ -51,6 +54,13 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option Option { + if params.get("on").and_then(Value::as_bool) == Some(true) { + return Some(crate::docking::command(app, false, &json!({"operation":"open", "panel":"toolSets"}))); + } toggle(&mut app.ui.show_toolsets, params); Ok(Value::Null) } @@ -210,8 +223,7 @@ pub fn run_ui_command(app: &mut CadApp, id: &str, params: &Value) -> Option { - app.ui.show_palettes = true; - Ok(Value::Null) + crate::docking::command(app, false, &json!({"operation":"open", "panel":"properties"})) } _ => return None, }; diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 347329ff..c8c30a19 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -121,7 +121,7 @@ fn command_known(id: &str) -> bool { pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); - egui::Panel::left("cc_toolsets").exact_size(220.0).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { + egui::Frame::NONE.fill(t.panel).show(ui, |ui| { let r = ui.max_rect(); let p = ui.painter().clone(); // Tabs. @@ -134,6 +134,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let resp = ui.interact(tr, ui.id().with(("ts", name)), Sense::click()); p.rect_filled(tr, 0.0, if active { t.tab_active } else { t.chrome }); p.text(tr.center(), egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(13.5), if active { t.text } else { t.text_dim }); + icons::describe_control(ui, &resp, name, Some(active), true); if resp.clicked() { app.ui.toolset_tab = name.into(); } @@ -143,6 +144,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); let cresp = ui.interact(cr, ui.id().with("ts_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronLeft, false); + icons::describe_control(ui, &cresp, "Collapse Tool Sets", None, false); if cresp.on_hover_text("Collapse Tool Sets").clicked() { app.ui.show_toolsets = false; } @@ -166,6 +168,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { ); pp.text(pos2(hr.left() + 22.0, hr.center().y), egui::Align2::LEFT_CENTER, *name, egui::FontId::proportional(12.5), t.text); icons::paint(pp, Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(11.0, 11.0)), Icon::Gear, false); + icons::describe_control(ui, &hresp, name, Some(!collapsed), false); if hresp.clicked() { toggle_group = Some(name.to_string()); } @@ -273,7 +276,7 @@ pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) { }); } -fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { +pub(crate) fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); section_header(ui, "Layers"); // Layer tools row. @@ -553,7 +556,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { } } -fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { +pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); properties_header(app, ui); let sel = app.session.selection(); diff --git a/crates/ui-egui/tests/chrome_geometry.rs b/crates/ui-egui/tests/chrome_geometry.rs new file mode 100644 index 00000000..23da2f3c --- /dev/null +++ b/crates/ui-egui/tests/chrome_geometry.rs @@ -0,0 +1,86 @@ +//! The real app's painted geometry at native and compact window sizes. + +use cadcraft_engine::Session; +use cadcraft_geom::Vec2; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Rect, Shape, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +fn harness(width: f32, height: f32, scale: f32, message: bool) -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.run("new", serde_json::json!({})).unwrap(); + app.ui.start_tab = false; + app.ui.in_window_menu = false; + app.integrated_titlebar = true; + if message { + app.set_status("Drawing saved successfully with a long status message"); + } + let mut h = Harness::builder().with_size(vec2(width, height)).with_pixels_per_point(scale).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.canvas.cursor = Some(Vec2::new(1234.5, -9876.25)); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +fn visible_text(shape: &Shape, clip: Rect, result: &mut Vec<(String, Rect)>) { + match shape { + Shape::Text(text) => { + let rect = text.visual_bounding_rect().intersect(clip); + if rect.is_positive() { + result.push((text.galley.text().to_string(), rect)); + } + } + Shape::Vec(shapes) => { + for shape in shapes { + visible_text(shape, clip, result); + } + } + _ => {} + } +} + +#[test] +fn status_labels_never_overlap_at_standard_and_compact_sizes() { + let mut overlaps = Vec::new(); + for (width, height) in [(1200.0, 760.0), (900.0, 560.0)] { + for scale in [1.0, 2.0] { + for message in [false, true] { + let mut h = harness(width, height, scale, message); + if let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{message}.png")).unwrap(); + } + let mut texts = Vec::new(); + for shape in &h.output().shapes { + visible_text(&shape.shape, shape.clip_rect, &mut texts); + } + texts.retain(|(_, rect)| rect.center().y > height - 26.0); + assert!(texts.iter().any(|(text, _)| text == "Layout2")); + for (i, (first, first_rect)) in texts.iter().enumerate() { + for (second, second_rect) in texts.iter().skip(i + 1) { + if first_rect.intersects(*second_rect) { + overlaps.push(format!("{width}px @{scale}: {first:?} {first_rect:?} overlaps {second:?} {second_rect:?}")); + } + } + } + } + } + } + assert!(overlaps.is_empty(), "{}", overlaps.join("\n")); +} + +#[test] +fn compact_toolbar_overflow_keeps_help_reachable() { + let mut h = harness(900.0, 560.0, 1.0, false); + h.get_by_label("More toolbar commands").click(); + h.run_steps(3); + h.get_by_label("Help").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_deref(), Some("about")); +} diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/docking_workflows.rs new file mode 100644 index 00000000..bf356f82 --- /dev/null +++ b/crates/ui-egui/tests/docking_workflows.rs @@ -0,0 +1,336 @@ +//! Panel moves use the real app shell, retain commands, and survive a complete UI-state reload. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel}; +use egui::{Event, Modifiers, PointerButton, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +fn harness(state: Option, width: f32, scale: f32) -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.run("rectang", json!({"p1":[30,30],"p2":[170,110]})).unwrap(); + app.run("circle", json!({"center":[100,70],"radius":26})).unwrap(); + app.run("line", json!({"points":[[30,30],[170,110]]})).unwrap(); + app.run("view.set", json!({"center":[100,70],"height":220})).unwrap(); + if let Some(state) = state { + app.ui = state; + } + app.ui.in_window_menu = false; + app.integrated_titlebar = true; + let mut harness = Harness::builder().with_size(vec2(width, 760.0)).with_pixels_per_point(scale).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + harness.run_steps(5); + harness +} + +fn drag(harness: &mut Harness<'_, CadApp>, from: egui::Pos2, to: egui::Pos2) { + harness.event(Event::PointerMoved(from)); + harness.run_steps(2); + harness.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + harness.run_steps(2); + for step in 1..=8 { + harness.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0))); + harness.run_steps(1); + } + harness.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + harness.run_steps(4); +} + +#[test] +fn real_panel_drag_float_close_reopen_and_full_workspace_reload() { + let mut harness = harness(None, 1280.0, 1.0); + let source = harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").rect().center(); + let target = harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Tool Sets").rect().center(); + drag(&mut harness, source, target); + let location = harness.state().ui.docking.layout.location(&Panel::Layers).unwrap(); + assert_eq!(location.anchor, Some(Panel::ToolSets)); + harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary); + harness.run_steps(3); + harness.get_by_label("Float panel").click(); + harness.run_steps(5); + let rect = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect; + harness.get_by_role_and_label(egui::accesskit::Role::Tab, "Layers").click_button(PointerButton::Secondary); + harness.run_steps(3); + harness.get_by_label("Close panel").click(); + harness.run_steps(4); + assert!(!harness.state().ui.docking.layout.contains(&Panel::Layers)); + harness.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + harness.run_steps(3); + harness.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); + harness.run_steps(4); + assert_eq!(harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Layers)).unwrap().rect, rect); + let saved = serde_json::to_string(&harness.state().ui).unwrap(); + let expected = serde_json::to_value(&harness.state().ui.docking).unwrap(); + let mut reloaded = self::harness(Some(serde_json::from_str(&saved).unwrap()), 1280.0, 1.0); + assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected); + reloaded.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center"})).unwrap(); + reloaded.run_steps(4); + reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tool Sets").click(); + reloaded.run_steps(3); + reloaded.get_by_label("Line").click(); + reloaded.run_steps(3); + assert_eq!(reloaded.state().session.running.as_ref().map(|command| command.id.as_str()), Some("line")); +} + +#[test] +fn invalid_requests_leave_document_and_panel_layout_unchanged() { + let mut harness = harness(None, 1000.0, 1.0); + let before = serde_json::to_value(&harness.state().ui.docking).unwrap(); + let drawing = harness.state().session.doc().unwrap().clone(); + for params in [ + json!({"operation":"float", "panel":"canvas", "rect":[100,100,400,300]}), + json!({"operation":"move", "panel":"toolSets", "target":"canvas", "zone":"center"}), + json!({"operation":"float", "panel":"layers", "rect":[0,0,-1,200]}), + json!({"operation":"move", "panel":"missing", "target":"canvas", "zone":"right"}), + ] { + assert!(harness.state_mut().run("ui.dock", params).is_err()); + assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before); + assert_eq!(harness.state().session.doc().unwrap(), &drawing); + } +} + +#[test] +fn docking_renders_default_float_and_redocked_at_normal_and_narrow_sizes() { + for width in [900.0, 1280.0] { + for scale in [1.0, 2.0] { + let mut harness = harness(None, width, scale); + for state in ["default", "floating", "redocked"] { + if state == "floating" { + harness.state_mut().run("ui.dock", json!({"operation":"float", "panel":"layers", "rect":[160,100,300,380]})).unwrap(); + } else if state == "redocked" { + harness + .state_mut() + .run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"center"})) + .unwrap(); + } + harness.run_steps(5); + harness.state().ui.docking.validate().unwrap(); + let content = harness.state().ui.docking.layout.panels(); + assert_eq!(content.len(), 4); + assert!(content.contains(&&Panel::Canvas)); + if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { + std::fs::create_dir_all(&directory).unwrap(); + harness.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{state}.png")).unwrap(); + } + } + } + } +} + +#[test] +fn automation_reaches_split_stack_resize_and_tab_reorder_through_the_same_handler() { + use craft_ui::{docking::Action, layout::SplitSize}; + let mut h = harness(None, 1280.0, 1.0); + h.state_mut() + .run( + "ui.dock", + json!({"action":serde_json::to_value(Action::::ResizeSplit { path: vec![], size: SplitSize::FixedFirst(280.0) }).unwrap()}), + ) + .unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"setStackOpen","panel":"layers","open":false})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"resizeStack","panel":"layers","height":160.0})).unwrap(); + assert_eq!(h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack, Some((false, Some(160.0)))); + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"layers","target":"properties","zone":"center","before":null})).unwrap(); + h.run_steps(4); + let panels = h.state().ui.docking.layout.panels(); + assert!(panels.iter().position(|p| **p == Panel::Properties).unwrap() < panels.iter().position(|p| **p == Panel::Layers).unwrap()); + assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap().size, Some(SplitSize::FixedFirst(280.0))); +} + +#[test] +fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() { + let mut h = harness(None, 1280.0, 1.0); + let drawing = h.state().session.doc().unwrap().clone(); + let center = h + .get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels") + .into_iter() + .find(|node| node.rect().width() > node.rect().height()) + .unwrap() + .rect() + .center(); + drag(&mut h, center, center + vec2(0.0, -45.0)); + let height = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap(); + h.get_all_by_role_and_label(egui::accesskit::Role::Splitter, "Resize panels") + .into_iter() + .find(|node| node.rect().width() > node.rect().height()) + .unwrap() + .focus(); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(4); + let after = h.state().ui.docking.layout.location(&Panel::Layers).unwrap().stack.unwrap().1.unwrap(); + assert!(after > height, "keyboard resize must reach the same panel command handler"); + assert_eq!(h.state().session.doc().unwrap(), &drawing); +} + +#[test] +fn grouped_restore_preserves_order_and_rectangle_in_both_close_orders_after_reload() { + use craft_ui::docking::{Action, Placement}; + for order in [[Panel::Layers, Panel::Properties], [Panel::Properties, Panel::Layers]] { + let mut workspace = cadcraft_ui_egui::docking::Workspace::default(); + let rect = [50.0, 60.0, 320.0, 400.0]; + workspace.apply(Action::Float { panel: Panel::Layers, rect }).unwrap(); + workspace + .apply(Action::Move { panel: Panel::Properties, anchor: Panel::Layers, placement: Placement::Tab { before: Some(Panel::Layers) } }) + .unwrap(); + let expected = workspace.layout.floating[0].panels.clone(); + for panel in order { + workspace.set_visible(panel, false).unwrap(); + } + let saved = serde_json::to_string(&workspace).unwrap(); + workspace = serde_json::from_str(&saved).unwrap(); + workspace.reveal_panel(Panel::Properties).unwrap(); + workspace.reveal_panel(Panel::Layers).unwrap(); + assert_eq!(workspace.layout.floating.len(), 1); + assert_eq!(workspace.layout.floating[0].panels, expected); + assert_eq!(workspace.layout.floating[0].rect, rect); + } +} + +#[test] +fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { + let mut harness = harness(None, 1000.0, 1.0); + harness.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap(); + harness.state_mut().cmdline("PROPERTIES"); + assert!(harness.state().ui.docking.layout.contains(&Panel::Properties)); + harness.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap(); + harness.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap(); + harness.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap(); + harness.state_mut().run("ui.panel.properties", json!({})).unwrap(); + harness.run_steps(3); + let location = harness.state().ui.docking.layout.location(&Panel::Properties).unwrap(); + assert_eq!(location.anchor, Some(Panel::Layers)); + let group = harness.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap(); + assert_eq!(group.panels.get(group.active), Some(&Panel::Properties)); +} + +#[test] +fn middle_press_over_floating_panel_does_not_pan_drawing() { + let mut harness = harness(None, 1280.0, 1.0); + harness.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap(); + harness.run_steps(4); + let before = harness.state().session.state().unwrap().view(); + let at = egui::pos2(450.0, 330.0); + harness.event(Event::PointerMoved(at)); + harness.event(Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: true, modifiers: Modifiers::NONE }); + harness.run_steps(2); + harness.event(Event::PointerMoved(at + egui::vec2(40.0, 25.0))); + harness.run_steps(3); + let after = harness.state().session.state().unwrap().view(); + assert_eq!(after.center, before.center); + assert_eq!(after.height, before.height); + harness.event(Event::PointerButton { pos: at, button: PointerButton::Middle, pressed: false, modifiers: Modifiers::NONE }); + harness.run_steps(2); +} + +fn type_command(h: &mut Harness<'_, CadApp>, command: &str) { + h.ctx.memory_mut(|memory| { + if let Some(id) = memory.focused() { + memory.surrender_focus(id); + } + }); + h.event(Event::Text(command.into())); + h.run_steps(1); + assert_eq!(h.state().cmd.buffer, command); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert!(h.state().cmd.buffer.is_empty()); +} + +#[test] +fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspace() { + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap(); + h.run_steps(3); + let drawing = h.state().session.doc().unwrap().clone(); + let tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(); + type_command(&mut h, "PROPERTIES"); + assert!(h.state().ui.docking.layout.contains(&Panel::Properties)); + assert!(h.state().ui.docking.layout.contains(&Panel::Layers)); + let restored_tools = h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(); + assert_eq!(restored_tools.anchor, tools.anchor); + assert_eq!(restored_tools.placement, tools.placement); + assert_eq!(restored_tools.size, tools.size); + assert_eq!(restored_tools.floating, tools.floating); + assert_eq!(restored_tools.stack, tools.stack); + assert!(restored_tools.sibling.contains(&Panel::Properties)); + + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,250,300,350]})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"properties","target":"layers","zone":"center"})).unwrap(); + h.state_mut().run("ui.dock", json!({"operation":"activate","panel":"layers"})).unwrap(); + h.run_steps(3); + type_command(&mut h, "PROPERTIES"); + let group = h.state().ui.docking.layout.floating.iter().find(|group| group.panels.contains(&Panel::Properties)).unwrap(); + assert_eq!(group.panels.get(group.active), Some(&Panel::Properties)); + assert_eq!(h.state().session.doc().unwrap(), &drawing); +} + +#[test] +fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() { + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"layers","rect":[400,200,300,350]})).unwrap(); + h.run_steps(3); + let drawing = h.state().session.doc().unwrap().clone(); + type_command(&mut h, "ui.dock.reset"); + let expected = cadcraft_ui_egui::docking::Workspace::default(); + assert_eq!(h.state().ui.docking.layout, expected.layout); + assert_eq!(h.state().ui.docking.hidden, expected.hidden); + assert_eq!(h.state().session.doc().unwrap(), &drawing); +} + +#[test] +fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { + use craft_ui::docking::Action; + use craft_ui::layout::SplitSize; + let mut h = harness(None, 1280.0, 1.0); + // Give the old root divider a size different from Reset's 220px ToolSets pane. + h.state_mut() + .run( + "ui.dock", + json!({"action":Action::::ResizeSplit { + path: Vec::new(), size: SplitSize::FixedFirst(280.0), + }}), + ) + .unwrap(); + h.run_steps(3); + let drawing = h.state().session.doc().unwrap().clone(); + let splitter = egui::Id::new("cadcraft-docking").with(("split", Vec::::new())); + let from = h.ctx.read_response(splitter).expect("rendered root divider").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); + // Canvas is visited before the ancestor ResizeHandle. The command runs before + // that ancestor queues this frame's ResizeSplit against the old immutable tree. + assert_eq!(h.ctx.dragged_id(), Some(splitter), "actual divider owns the pointer"); + assert!(h.ctx.input(|input| input.pointer.primary_down())); + // A ResizeHandle takes keyboard focus on press. Release only that keyboard focus + // so the real Canvas command line can submit while the pointer remains captured. + h.ctx.memory_mut(|memory| memory.surrender_focus(splitter)); + assert!(!h.ctx.egui_wants_keyboard_input()); + let to = from + vec2(36.0, 0.0); + h.event(Event::PointerMoved(to)); + h.event(Event::Text("ui.dock.reset".into())); + h.event(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + let expected = cadcraft_ui_egui::docking::Workspace::default(); + assert!(h.state().cmd.buffer.is_empty(), "Canvas submitted the actual reset command"); + assert_eq!(h.state().ui.docking.layout, expected.layout, "old-frame resize must not modify Reset's tree"); + assert_eq!(h.state().ui.docking.hidden, expected.hidden); + // Keep the physical button down: replacement must terminate the old divider gesture, + // rather than letting its stable root ID continue resizing the new workspace. + let later = to + vec2(24.0, 0.0); + h.event(Event::PointerMoved(later)); + h.run_steps(2); + assert_eq!(h.state().ui.docking.layout, expected.layout, "continued old drag must leave the replacement unchanged"); + h.event(Event::PointerButton { pos: later, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(h.state().ui.docking.layout, expected.layout); + assert_eq!(h.state().session.doc().unwrap(), &drawing); +} diff --git a/crates/ui-egui/tests/shared_controls.rs b/crates/ui-egui/tests/shared_controls.rs new file mode 100644 index 00000000..c5c9a14a --- /dev/null +++ b/crates/ui-egui/tests/shared_controls.rs @@ -0,0 +1,170 @@ +//! Interaction and optional offscreen evidence for the application's painted controls. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, icons, theme}; +use egui::{Event, Key, Modifiers, PointerButton, Response, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +fn app() -> CadApp { + let mut app = CadApp::new(Session::default(), Services::default()); + app.run("ui.sample", serde_json::json!({})).unwrap(); + app +} + +#[derive(Default)] +struct ButtonState { + selected: bool, + disabled: bool, + clicks: usize, + response: Option, +} + +fn button_frame(ctx: &egui::Context, state: &mut ButtonState, events: Vec) -> egui::FullOutput { + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + let response = ui.add_enabled_ui(!state.disabled, |ui| icons::button(ui, icons::Icon::Line, 40.0, "Draw line", state.selected)).inner; + state.clicks += usize::from(response.clicked()); + state.response = Some(response); + }); + output.textures_delta.clear(); + output +} + +fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE } +} + +fn pointer(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn drafting_button_preserves_multicolor_selection_and_accessibility() { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = ButtonState { selected: true, ..Default::default() }; + button_frame(&ctx, &mut state, vec![]); + let center = state.response.as_ref().unwrap().rect.center(); + let out = button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]); + let t = theme::Tokens::get(); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == t.tab_active))); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { stroke, .. } if stroke.color == t.icon_accent))); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Circle(c) if c.fill == t.icon_point))); + let update = out.platform_output.accesskit_update.unwrap(); + let node = &update.nodes.iter().find(|(_, n)| n.label() == Some("Draw line")).unwrap().1; + assert_eq!(node.role(), egui::accesskit::Role::Button); + assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True)); + button_frame(&ctx, &mut state, vec![pointer(center, false)]); + assert_eq!(state.clicks, 1); + assert!(state.selected, "selection belongs to the caller"); +} + +#[test] +fn drafting_button_keyboard_disabled_state_and_click_cancellation() { + for key_code in [Key::Enter, Key::Space] { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = ButtonState::default(); + button_frame(&ctx, &mut state, vec![]); + button_frame(&ctx, &mut state, vec![]); + let response = state.response.clone().unwrap(); + ctx.memory_mut(|m| m.request_focus(response.id)); + let out = button_frame(&ctx, &mut state, vec![]); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) + if r.rect == response.rect.shrink(2.0) && r.stroke.color == theme::Tokens::get().accent))); + button_frame(&ctx, &mut state, vec![key(key_code, true)]); + button_frame(&ctx, &mut state, vec![key(key_code, false)]); + assert_eq!(state.clicks, 1); + let center = response.rect.center(); + button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]); + button_frame(&ctx, &mut state, vec![Event::PointerMoved(pos2(300.0, 150.0))]); + button_frame(&ctx, &mut state, vec![pointer(pos2(300.0, 150.0), false)]); + assert_eq!(state.clicks, 1, "releasing away must not start a command"); + state.disabled = true; + button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true), key(key_code, true)]); + let out = button_frame(&ctx, &mut state, vec![pointer(center, false), key(key_code, false)]); + assert_eq!(state.clicks, 1); + assert!(!state.response.as_ref().unwrap().enabled()); + assert!(out.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, n)| n.label() == Some("Draw line") && n.is_disabled())); + } +} + +#[test] +fn drafting_palette_pointer_and_keyboard_start_the_same_commands() { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app(), + ); + h.run_steps(5); + h.get_by_label("Line").click(); + h.run_steps(3); + assert_eq!(h.state().session.running.as_ref().unwrap().id, "line"); + h.state_mut().session.cancel(); + for key in [Key::Enter, Key::Space] { + h.get_by_label("Circle").focus(); + h.run_steps(2); + assert!(h.get_by_label("Circle").is_focused()); + h.key_press(key); + h.run_steps(3); + assert_eq!(h.state().session.running.as_ref().unwrap().id, "circle"); + h.state_mut().session.cancel(); + } +} + +#[test] +fn shared_controls_visuals() { + let Some(directory) = std::env::var_os("CRAFT_UI_SUITE_SHOTS").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for scale in [1.0, 2.0] { + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app(), + ); + for (name, size) in [("normal", vec2(1600.0, 1000.0)), ("narrow", vec2(900.0, 600.0))] { + h.set_size(size); + h.run_steps(6); + h.render().unwrap().save(directory.join(format!("cadcraft-{name}-dark-{scale}x.png"))).unwrap(); + } + let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, rects: &mut Vec| { + theme::apply(ui.ctx(), egui::Theme::Dark); + rects.clear(); + for enabled in [true, false] { + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + for selected in [false, true] { + for icon in [icons::Icon::Line, icons::Icon::Circle, icons::Icon::Move] { + rects.push(icons::button(ui, icon, 40.0, "Drafting control", selected).rect); + } + } + }); + }); + } + }, + Vec::new(), + ); + controls.run_steps(4); + controls.render().unwrap().save(directory.join(format!("cadcraft-controls-dark-{scale}x.png"))).unwrap(); + for (name, index, pressed) in [("hover", 0, false), ("selected-hover", 3, false), ("disabled-hover", 6, false), ("pressed", 0, true)] { + let center = controls.state()[index].center(); + controls.event(Event::PointerMoved(center)); + if pressed { + controls.event(pointer(center, true)); + } + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("cadcraft-controls-{name}-dark-{scale}x.png"))).unwrap(); + } + controls.event(pointer(controls.state()[0].center(), false)); + controls.event(Event::PointerGone); + controls.run_steps(2); + controls.key_press(Key::Tab); + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("cadcraft-controls-focus-dark-{scale}x.png"))).unwrap(); + } +} diff --git a/crates/ui-egui/tests/workspace_focus.rs b/crates/ui-egui/tests/workspace_focus.rs new file mode 100644 index 00000000..75fbd8c7 --- /dev/null +++ b/crates/ui-egui/tests/workspace_focus.rs @@ -0,0 +1,96 @@ +//! Real key traversal of the custom painted controls, without assigning widget focus. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Key, Modifiers, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +fn harness() -> Harness<'static, CadApp> { + let mut app = CadApp::new(Session::default(), Services::default()); + app.integrated_titlebar = true; + app.ui.in_window_menu = false; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +fn tab_to(h: &mut Harness<'_, CadApp>, label: &str) { + for _ in 0..160 { + if h.query_by_label(label).is_some_and(|node| node.is_focused()) { + return; + } + h.key_press(Key::Tab); + h.run_steps(2); + } + panic!("Tab traversal did not reach {label}"); +} + +#[test] +fn toolbar_tab_and_shift_tab_skip_title_drag_and_activate_once() { + let mut h = harness(); + h.key_press(Key::Tab); + h.run_steps(2); + assert!(h.get_by_label("New Drawing (⌘N)").is_focused(), "the title drag region is not a keyboard control"); + h.key_press(Key::Tab); + h.run_steps(2); + assert!(h.get_by_label("Open (⌘O)").is_focused()); + h.key_press_modifiers(Modifiers::SHIFT, Key::Tab); + h.run_steps(2); + assert!(h.get_by_label("New Drawing (⌘N)").is_focused()); + let before = h.state().session.docs.len(); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().session.docs.len(), before + 1); +} + +#[test] +fn toolset_tabs_traverse_and_activate_without_drafting_commands() { + let mut h = harness(); + tab_to(&mut h, "Drafting"); + h.key_press(Key::Tab); + h.run_steps(2); + assert!(h.get_by_label("Modeling").is_focused()); + h.key_press(Key::Space); + h.run_steps(3); + assert_eq!(h.state().ui.toolset_tab, "Modeling"); + assert!(h.state().session.running.is_none()); + h.key_press_modifiers(Modifiers::SHIFT, Key::Tab); + h.run_steps(2); + assert!(h.get_by_label("Drafting").is_focused()); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().ui.toolset_tab, "Drafting"); + tab_to(&mut h, "Line"); + h.key_press(Key::Tab); + h.run_steps(2); + assert!(h.get_by_label("Polyline").is_focused()); + h.key_press_modifiers(Modifiers::SHIFT, Key::Tab); + h.run_steps(2); + assert!(h.get_by_label("Line").is_focused()); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(h.state().session.running.as_ref().map(|command| command.id.as_str()), Some("line")); +} + +#[test] +fn document_tabs_have_distinct_keyboard_identity_and_activation() { + let mut h = harness(); + h.state_mut().run("new", serde_json::json!({})).unwrap(); + h.run_steps(3); + let title = h.state().session.docs[0].title.clone(); + tab_to(&mut h, "Start"); + h.key_press(Key::Tab); + h.run_steps(2); + assert!(h.get_by_label(&title).is_focused()); + h.key_press(Key::Space); + h.run_steps(3); + assert_eq!(h.state().session.active, 0); + assert!(!h.state().ui.start_tab); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2353ab48..015a6d5a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -19,7 +19,8 @@ Errors come back as `{"id": …, "ok": false, "error": "message"}`. | `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. | | `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. | | `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. | -| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters; `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none). | +| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters; `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | +| `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. | | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). | | `ui.pointer` | `{x, y, space?: "world"\|"screen", button?, action?: "click"\|"move"}` | Click in the drawing area (world coordinates by default). Right button = Enter. | | `ui.click` / `ui.move` | `{x, y, button?, shift?}` | Real egui pointer events in screen points (reach every widget). | @@ -27,9 +28,45 @@ Errors come back as `{"id": …, "ok": false, "error": "message"}`. | `ui.key` / `ui.text` | `{key, cmd?, shift?, alt?}` / `{text}` | Synthetic keyboard input. | | `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). | | `ui.resize` | `{width, height}` | Resize the window. | +| `ui.focus` | | Activate the native window. Does not assign focus to a widget. | | `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). | | `ui.render` | `{path?, width?, height?}` | Headless render of the current view of the drawing. | | `app.open` / `app.save` / `app.quit` | `{path}` | File operations. | The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless (`cadcraft-cli mcp`) it serves the same methods from an in-process session. + +`ui.key` queues real egui key events; its reply acknowledges the queue. Poll `ui.inspect` or +`ui.elements` for the resulting focus or state before sending the next action. Use Tab and +Shift-Tab to traverse controls and Enter/Space to activate them. The observed registry is not +the operating system's accessibility tree and does not establish physical input or screen-reader +acceptance. Unregistered controls still report their actual focus ID, with a null label/role. + +The native host restores layout choices from `workspace.json` and saves changes atomically. +It stores palette/toolset visibility, selected toolset, collapsed groups, command-line history +height and related chrome choices. It does not store drawings, command buffers, dialogs or the +current Start tab. The default directory is `~/Library/Application Support/CADCraft` on macOS, +`%APPDATA%/CADCraft` on Windows, and `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` elsewhere. +Set `CADCRAFT_CONFIG_DIR` to a private directory for an isolated profile; an explicitly empty +value disables workspace reads and writes. An invalid, oversized or newer-version file is +reported and left untouched, with persistence disabled for that launch. Repair or move that +file before restarting to resume saving workspace choices. + +## Panel docking + +Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents. + +Panel IDs: `toolSets`, `layers`, `properties`. The protected center is `canvas`. + +`ui.dock` accepts `operation`; basic per-panel operations also require `panel`. The advanced `resizeSplit` operation below uses a split `path` instead of a panel. Basic operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. + +Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"layers","rect":[100,100,320,440]}}`. + +Advanced `ui.dock` operations use the same validated transaction as pointer gestures: +`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and +`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), +`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. +A center `move` can include `before` (panel ID or null to append) for tab order. +The equivalent `{"action": ...}` form accepts a serialized shared docking action; +this is also the form emitted by the renderer. Protected-canvas and app-specific panel +rules apply equally to both forms. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index e6075d4b..0017c10b 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -69,7 +69,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", "egui-wgpu", "wgpu", "winit", "egui_kittest", "rfd", "bevy*"]; +pub const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "egui-wgpu", "wgpu", "winit", "egui_kittest", "rfd", "bevy*"]; /// First layer allowed to use UI crates. pub const UI_MIN_LAYER: u8 = 5; @@ -282,7 +282,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("cadcraft-engine", &[(dep, Normal, false)])]); assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 4, .. }]), "{dep}"); }