diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 54439c12e..e124d102a 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -24,10 +24,10 @@ Generated-in-code assets are original and have no file to list: | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| | `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | -| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | -| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | -| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | -| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs a091680 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | +| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | +| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | +| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | +| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | | `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | diff --git a/Cargo.lock b/Cargo.lock index 4dd434595..b646729fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -571,7 +571,9 @@ dependencies = [ "cadcraft-mcp", "cadcraft-ui-egui", "chrono", + "craft-ui", "craft-update", + "craft-window", "eframe", "egui", "hidapi", @@ -740,6 +742,7 @@ dependencies = [ "chrono", "craft-ui", "craft-update", + "craft-window", "egui", "egui-wgpu", "egui_kittest", @@ -992,12 +995,12 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-syntax", "krilla", @@ -1007,7 +1010,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "egui", "log", @@ -1020,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "chrono", "dunce", @@ -1042,6 +1045,17 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" +dependencies = [ + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -1981,7 +1995,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-ccitt", ] @@ -1995,7 +2009,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2357,7 +2371,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=a0916808f7d78bed8c7b90cdf937176015454a7f#a0916808f7d78bed8c7b90cdf937176015454a7f" +source = "git+https://github.com/storytold/craft-libs?rev=9f89fe8f6d72ab4db4cc06f186381a6a254463ab#9f89fe8f6d72ab4db4cc06f186381a6a254463ab" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 9ceb02e4d..db0d88092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,10 +25,10 @@ cadcraft-engine = { path = "crates/engine" } cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } # Which paths stored in a drawing (xrefs, data links) may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). -craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", default-features = false } # foundation thiserror = "2" @@ -53,14 +53,15 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f", features = ["serde"] } +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["serde"] } +craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] } egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = "0.37" eframe = { version = "0.37", default-features = false } egui-wgpu = "0.37" # Monotonic time on native and web (performance.now on wasm). web-time = "1.1" -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab", features = ["egui"] } rfd = "0.17" arboard = { version = "3.6", default-features = false } @@ -90,9 +91,9 @@ debug = 0 # PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself # (Cargo ignores dependency-local patches). [patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "a0916808f7d78bed8c7b90cdf937176015454a7f" } +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9f89fe8f6d72ab4db4cc06f186381a6a254463ab" } # Keep the complete egui family and native event bridge on reviewed fork commits. ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index 3e6cc03a3..2eb24b221 100644 --- a/NOTICE +++ b/NOTICE @@ -28,9 +28,9 @@ The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds fo Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md. Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc -(pinned through https://github.com/storytold/craft-libs/tree/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc). +(pinned through https://github.com/storytold/craft-libs/tree/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc). CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ -Pinned licence text: https://github.com/storytold/craft-libs/blob/a0916808f7d78bed8c7b90cdf937176015454a7f/vendor/krilla/icc/LICENSE +Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/LICENSE The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts and registered through egui's default font definitions. They derive from emoji-icon-font by diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 4c88f2ad6..48c2c2d15 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -34,11 +34,16 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page. +craft-ui = { workspace = true, features = ["native-windows"] } craft-update = { workspace = true } rfd = { workspace = true } sys-locale = "0.3" [target.'cfg(target_os = "macos")'.dependencies] +# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's +# controls and under floating panels. +craft-window = { workspace = true } muda = { version = "0.21", default-features = false } # View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion # (system_animations.rs). Already in Cargo.lock through muda. diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 45852da2f..1007fc0c1 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -39,11 +39,22 @@ struct App { workspace_error: Option, #[cfg(target_os = "macos")] native_menu: Option, + /// The `movable` flag last set on the main window. + #[cfg(target_os = "macos")] + movable: 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) }; + /// `ctx` is `None` only for the final attempt at exit. + fn persist_workspace(&mut self, ctx: Option<&egui::Context>) { + let result = match ctx { + None => self.workspace.save_on_exit(&self.cad.ui), + Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| { + if let Some(wait) = wait { + ctx.request_repaint_after(wait); + } + }), + }; if let Err(error) = result { if self.workspace_error.as_ref() != Some(&error) { eprintln!("CADCraft workspace: {error}"); @@ -86,7 +97,7 @@ impl eframe::App for App { } self.spacemouse.poll(&mut self.cad, ctx); // After `cad.logic`, so a control command's workspace change is saved in the same frame. - self.persist_workspace(false); + self.persist_workspace(Some(ctx)); } if std::mem::take(&mut self.cad.quit_requested) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -100,11 +111,13 @@ impl eframe::App for App { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.cad.ui(ui); + #[cfg(target_os = "macos")] + craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable); } /// A normal exit: the workspace is saved, and this run's autosave files are not needed (a /// crash leaves them for recovery). fn on_exit(&mut self) { - self.persist_workspace(true); + self.persist_workspace(None); self.cad.cancel_screenshots(); self.cad.shutdown_autosave(); } @@ -405,6 +418,8 @@ fn main() -> eframe::Result { workspace_error, #[cfg(target_os = "macos")] native_menu: None, + #[cfg(target_os = "macos")] + movable: None, })) }), ) diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs index f4e34d4fb..b8e541ed3 100644 --- a/apps/cadcraft/src/workspace.rs +++ b/apps/cadcraft/src/workspace.rs @@ -5,11 +5,15 @@ use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; use cadcraft_ui_egui::UiState; use serde_json::{Value, json}; const MAX_BYTES: u64 = 64 * 1024; +/// Dragging a floating window changes its rect every frame; writes (each with an fsync) are +/// spaced at least this far apart, and the newest state is written when the interval ends. +const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(500); static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); pub struct Workspace { @@ -17,6 +21,7 @@ pub struct Workspace { last_attempt: Option, read_failed: bool, pending_write_error: Option, + last_write: Option, } fn config_dir_from(platform: &str, get: impl Fn(&str) -> Option) -> Option { @@ -44,6 +49,7 @@ fn snapshot(ui: &UiState) -> Result { } Ok(json!({ "docking": ui.docking, + "dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION, "showToolsets": ui.show_toolsets, "showPalettes": ui.show_palettes, "showToolbar": ui.show_toolbar, @@ -80,10 +86,16 @@ fn read(path: &Path) -> Result, String> { value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?; // Dock schemas can evolve independently of the other saved workspace preferences. // Recover only this optional field; runtime UiState deserialization remains strict. + // A layout from before the current shape (split frames, saved without `dockingVersion`) + // gives way to the default, its hidden panels with it. let mut fields = workspace.clone(); - let docking = fields.as_object_mut().and_then(|fields| fields.remove("docking")); + let (docking, version) = match fields.as_object_mut() { + Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")), + None => (None, None), + }; + let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION)); let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?; - if let Some(docking) = docking + if let Some(docking) = docking.filter(|_| current) && let Ok(docking) = serde_json::from_value::(docking) && cadcraft_ui_egui::docking::validate(&docking).is_ok() { @@ -136,38 +148,47 @@ impl Workspace { 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) + (Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None) } Err(error) => ( - Self { path, last_attempt: None, read_failed: true, pending_write_error: None }, + Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: 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(()) }; + /// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change + /// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it. + pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result, String> { + let Some(path) = &self.path else { return Ok(None) }; if self.read_failed { // Preserve a corrupt, oversized or newer-version file until the user repairs it. - return Ok(()); + return Ok(None); } let value = snapshot(ui)?; if self.last_attempt.as_ref() == Some(&value) { - return self.pending_write_error.clone().map_or(Ok(()), Err); + return self.pending_write_error.clone().map_or(Ok(None), Err); + } + if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last))) + && !wait.is_zero() + { + return Ok(Some(wait)); } // Keep a failed state's warning without retrying disk I/O every rendered frame. self.last_attempt = Some(value.clone()); + self.last_write = Some(now); let result = write(path, &value); self.pending_write_error = result.as_ref().err().cloned(); - result + result.map(|()| None) } 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) + self.last_write = None; + self.save_if_changed(ui, Instant::now()).map(|_| ()) } } @@ -216,7 +237,7 @@ mod tests { start_tab: true, ..Default::default() }; - first.save_if_changed(&ui).unwrap(); + first.save_if_changed(&ui, Instant::now()).unwrap(); let bytes = fs::read(&path).unwrap(); let value: Value = serde_json::from_slice(&bytes).unwrap(); assert!(value["workspace"].get("dialog").is_none()); @@ -229,37 +250,42 @@ mod tests { let mut transient_only = ui.clone(); transient_only.dialog = None; transient_only.start_tab = false; - first.save_if_changed(&transient_only).unwrap(); + first.save_if_changed(&transient_only, Instant::now()).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, permissions}; + fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() { + use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_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(); + app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap(); + app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap(); ui.docking = app.ui.docking; ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap(); ui.show_toolsets = false; - store.save_if_changed(&ui).unwrap(); + store.save_if_changed(&ui, Instant::now()).unwrap(); + let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION)); 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, None, permissions).unwrap(); - assert!(reloaded.docking.layout.contains(&Panel::ToolSets)); + assert!(!reloaded.show_toolsets); + reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap(); + let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap(); + assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}"); assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]); fs::remove_dir_all(directory).unwrap(); } #[test] - fn version_one_docking_file_still_loads() { - use cadcraft_ui_egui::docking::{Panel, reveal_panel}; + fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() { + use cadcraft_ui_egui::docking::default_workspace; // Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a // floating window as one `{panels, active, rect}` group, Tool Sets closed. let file = r#"{"version":1,"workspace":{ @@ -271,28 +297,18 @@ mod tests { "floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]}, "hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"}, "size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]}, - "showToolsets":false,"showPalettes":true,"toolsetTab":"Modeling","historyLines":4}}"#; + "showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#; let directory = temp_dir(); let path = directory.join("workspace.json"); fs::write(&path, file).unwrap(); let (_, restored, error) = Workspace::at(Some(path)); assert!(error.is_none(), "{error:?}"); - let mut ui = restored.unwrap(); + let ui = restored.unwrap(); assert_eq!(ui.toolset_tab, "Modeling"); - let docking = &ui.docking; - assert_eq!(docking.layout.floating.len(), 1); - let window = &docking.layout.floating[0]; - assert_eq!(window.rect, [120.0, 90.0, 340.0, 410.0]); - assert_eq!(serde_json::to_value(&window.root).unwrap(), json!({"Tabs":{"panels":["layers","externalReferences"],"active":1}})); - assert_eq!( - serde_json::to_value(&docking.layout.root).unwrap()["Split"]["second"], - json!({"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}) - ); - assert!(!docking.layout.contains(&Panel::ToolSets)); - reveal_panel(&mut ui.docking, Panel::ToolSets).unwrap(); - let tools = ui.docking.layout.location(&Panel::ToolSets).unwrap(); - assert_eq!(tools.anchor, Some(Panel::Canvas)); - assert_eq!(serde_json::to_value((&tools.placement, &tools.size)).unwrap(), json!([{"Split":"Left"}, {"FixedFirst":220.0}])); + assert_eq!(ui.history_lines, 4); + assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar); + assert_eq!(ui.docking.layout, default_workspace().layout); + assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden); fs::remove_dir_all(directory).unwrap(); } @@ -311,7 +327,7 @@ mod tests { 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(); + store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap(); assert_eq!(fs::read(&path).unwrap(), bytes); } fs::remove_dir_all(directory).unwrap(); @@ -326,7 +342,8 @@ mod tests { json!({"layout":false,"hidden":[]}), json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}), ] { - let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking}); + let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking, + "dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION}); assert!(serde_json::from_value::(workspace.clone()).is_err(), "runtime state must stay strict"); fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap(); let (_, restored, error) = Workspace::at(Some(path.clone())); @@ -342,16 +359,41 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } + #[test] + fn rapid_changes_are_written_once_per_interval_with_the_newest_state() { + let directory = temp_dir(); + let path = directory.join("workspace.json"); + let (mut store, _, _) = Workspace::at(Some(path.clone())); + let start = Instant::now(); + let mut ui = UiState { history_lines: 1, ..Default::default() }; + assert_eq!(store.save_if_changed(&ui, start).unwrap(), None); + // A drag: a new state every frame, none of them written until the interval ends. + for (frame, lines) in (2..=5).enumerate() { + ui.history_lines = lines; + let now = start + Duration::from_millis(16 * (frame as u64 + 1)); + let wait = store.save_if_changed(&ui, now).unwrap(); + assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start))); + } + assert_eq!(read(&path).unwrap().unwrap().history_lines, 1); + assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None); + assert_eq!(read(&path).unwrap().unwrap().history_lines, 5); + ui.history_lines = 6; + assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some()); + store.save_on_exit(&ui).unwrap(); + assert_eq!(read(&path).unwrap().unwrap().history_lines, 6); + 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(); + store.save_if_changed(&ui, Instant::now()).unwrap(); let good = fs::read(&path).unwrap(); ui.collapsed_groups = vec!["x".repeat(257)]; - assert!(store.save_if_changed(&ui).is_err()); + assert!(store.save_if_changed(&ui, Instant::now()).is_err()); assert_eq!(fs::read(&path).unwrap(), good); fs::remove_dir_all(directory).unwrap(); } @@ -364,9 +406,9 @@ mod tests { // 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(); + let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err(); fs::remove_dir(&path).unwrap(); - assert_eq!(store.save_if_changed(&ui).unwrap_err(), first_error); + assert_eq!(store.save_if_changed(&ui, Instant::now()).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); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index aee4c47f7..d21c2d8ca 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -17,6 +17,7 @@ cadcraft-doc = { workspace = true } cadcraft-render = { workspace = true } cadcraft-engine = { workspace = true } cadcraft-fonts = { workspace = true } +craft-window = { workspace = true } egui = { workspace = true } # GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature). egui-wgpu = { workspace = true } @@ -31,6 +32,8 @@ web-time = { workspace = true } craft-update = { workspace = true } [dev-dependencies] +# Tests build the dock area as the desktop app does (headless contexts keep floats in-window). +craft-ui = { workspace = true, features = ["native-windows"] } # The workspace `egui` dependency enables `default_fonts` and `monochrome_emoji_fonts` in # production too; the tests exercise the same installed chain as the app. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 0e79f8444..f3a935d79 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -69,6 +69,8 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { egui::Panel::top("cc_title_toolbar").exact_size(title_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let r = ui.max_rect(); if title_h > 0.0 { + // The empty row moves the window; it goes first so later widgets take their own clicks. + craft_window::title_bar::empty_area(ui, Rect::from_min_size(r.min, vec2(r.width(), title_h))); let title = match app.session.state() { Ok(st) if !app.ui.start_tab => format!("CADCraft {}{}", st.title, if st.title.contains('.') { "" } else { ".dwg" }), _ => format!("CADCraft {}", crate::tl!("Start")), @@ -78,16 +80,6 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim); let x = title_left(r.left(), r.width(), galley.size().x); ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim); - // 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.scope_id().with("titledrag"), Sense::CLICK | Sense::DRAG); - if resp.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if resp.double_clicked() { - let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } } if tb_h > 0.0 { let row = Rect::from_min_size(pos2(r.left(), r.top() + title_h), vec2(r.width(), tb_h)); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 6c1e624b5..bbbf08902 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -2,8 +2,8 @@ //! Document contents remain owned by their existing view implementations. use craft_ui::docking::{ - DockArea, DockCommand, DockOperation, DockStyle, FloatSpace, Layout, Location, Node, PanelLimits, Permissions, Placement, StackEntry, UiDock, - Zone, + Action, Column, DockArea, DockCommand, DockContent, DockHeights, DockOperation, DockStyle, FloatSpace, Group, Layout, Location, Node, + PanelLimits, Permissions, Placement, Side, Sides, Target, UiDock, Zone, }; use craft_ui::layout::{SplitAxis, SplitSize}; use serde::{Deserialize, Serialize}; @@ -43,6 +43,18 @@ impl Panel { Self::ExternalRefs => "External References", } } + /// The icon a palette shows in a column collapsed to icons. + pub fn icon(self) -> Option { + use crate::icons::Icon; + match self { + Self::Canvas => None, + Self::ToolSets => Some(Icon::Switcher), + Self::QuickCalc => Some(Icon::Table), + Self::Layers => Some(Icon::Layers), + Self::Properties => Some(Icon::Properties), + Self::ExternalRefs => Some(Icon::Attach), + } + } /// Palettes outside the default arrangement, opened on request (QUICKCALC). pub const ON_DEMAND: &[Self] = &[Self::QuickCalc]; } @@ -63,31 +75,106 @@ fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node; -/// Tool Sets on the left, the drawing, and the Layers and Properties accordion on the right. +/// The panel layout's shape version, saved beside it. Layouts saved without it, or with an +/// older one, are the split frames from before the dock columns and give way to the default. +pub const LAYOUT_VERSION: u32 = 2; + +/// Width of the left dock's Tool Sets column. +pub const TOOL_SETS_WIDTH: f32 = 220.0; +/// Width of the right dock's palettes column. +pub const PALETTES_WIDTH: f32 = 300.0; +/// Layers' body height above Properties in a dock column. +const LAYERS_HEIGHT: f32 = 280.0; + +fn dock(side: Side, width: f32, groups: &[Panel]) -> Node { + let groups = groups.iter().map(|panel| Group::new(vec![*panel], 0)).collect(); + Node::Dock { side, columns: vec![Column::new(groups, width)] } +} + +/// Tool Sets in the left dock, the drawing, and Layers above Properties in the right dock. pub fn default_workspace() -> Workspace { Workspace::new(Layout { root: Some(split( SplitAxis::Horizontal, - SplitSize::FixedFirst(220.0), - tabs(Panel::ToolSets), + SplitSize::FixedFirst(TOOL_SETS_WIDTH), + dock(Side::Start, TOOL_SETS_WIDTH, &[Panel::ToolSets]), split( SplitAxis::Horizontal, - SplitSize::FixedSecond(300.0), + SplitSize::FixedSecond(PALETTES_WIDTH), tabs(Panel::Canvas), - Node::Stack { - entries: vec![ - StackEntry { panel: Panel::Layers, open: true, height: Some(280.0) }, - StackEntry { panel: Panel::Properties, open: true, height: None }, - ], - }, + dock(Side::End, PALETTES_WIDTH, &[Panel::Layers, Panel::Properties]), ), )), floating: Vec::new(), }) } +/// The dock a panel belongs in: Tool Sets on the left, the palettes on the right. +fn home_side(panel: Panel) -> (Side, f32) { + if panel == Panel::ToolSets { (Side::Start, TOOL_SETS_WIDTH) } else { (Side::End, PALETTES_WIDTH) } +} + +/// The panels of every dock group on `side`: columns from the window edge inwards, groups from +/// top to bottom. +fn dock_groups(layout: &Layout, side: Side) -> Vec>> { + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + let mut seen = 0usize; + while let Some(node) = nodes.pop() { + seen = seen.saturating_add(1); + if seen > 4 * craft_ui::docking::MAX_PANELS { + break; + } + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: s, columns } if *s == side => { + return columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect(); + } + _ => {} + } + } + Vec::new() +} + +fn docked(layout: &Layout, panel: Panel) -> bool { + [Side::Start, Side::End].into_iter().any(|side| dock_groups(layout, side).iter().flatten().flatten().any(|p| *p == panel)) +} + +/// The docked neighbour a panel with no recorded place opens beside, as a new group above +/// (`false`) or below (`true`) it: Layers above Properties, External References and QuickCalc +/// below them, Tool Sets at the top of the left dock. +fn home_anchor(layout: &Layout, panel: Panel) -> Option<(Panel, bool)> { + let neighbours: &[(Panel, bool)] = match panel { + Panel::Canvas | Panel::ToolSets => &[], + Panel::Layers => &[(Panel::Properties, false), (Panel::ExternalRefs, false)], + Panel::Properties => &[(Panel::Layers, true), (Panel::ExternalRefs, false)], + Panel::ExternalRefs => &[(Panel::Properties, true), (Panel::Layers, true)], + Panel::QuickCalc => &[(Panel::ExternalRefs, true), (Panel::Properties, true), (Panel::Layers, true)], + }; + if let Some(found) = neighbours.iter().copied().find(|(neighbour, _)| docked(layout, *neighbour)) { + return Some(found); + } + let (side, _) = home_side(panel); + let columns = dock_groups(layout, side); + let column = columns.first()?; + if panel == Panel::ToolSets { + column.first().and_then(|group| group.first()).map(|anchor| (*anchor, false)) + } else { + column.last().and_then(|group| group.first()).map(|anchor| (*anchor, true)) + } +} + +/// The actions that open a panel with no recorded place in its dock: a new group beside its +/// docked neighbour, else a new dock on its side at its default width. +fn home(layout: &Layout, panel: Panel) -> Vec> { + if let Some((anchor, below)) = home_anchor(layout, panel) { + return vec![Action::OpenAt { panel, anchor, placement: Placement::Group { below, height: None } }]; + } + let (side, width) = home_side(panel); + vec![Action::Open { panel, anchor: None }, Action::MovePanel { panel, to: Target::NewDock { side } }, Action::ResizeColumn { panel, width }] +} + /// craft-ui's checks plus the drawing view's: a saved or hand-edited file cannot hide, float, -/// group or collapse it, or restore a palette over it. +/// group, collapse or dock it, or restore a palette over it. pub fn validate(workspace: &Workspace) -> Result<(), String> { workspace.validate().map_err(|error| error.to_string())?; if !workspace.layout.contains(&Panel::Canvas) { @@ -124,11 +211,17 @@ pub fn validate(workspace: &Workspace) -> Result<(), String> { Ok(()) } -/// Show a panel and select it at its recorded place (craft-ui places a panel it has no place -/// for), keeping the drawing view's rules. +/// Show a panel and select it at its recorded place, keeping the drawing view's rules. A panel +/// with no recorded place opens in its dock ([`home`]); one whose recorded place is gone joins +/// its docked neighbour's group. pub fn reveal_panel(workspace: &mut Workspace, panel: Panel) -> Result<(), String> { let mut next = workspace.clone(); - next.set_visible(panel, true, None, permissions).map_err(|error| error.to_string())?; + if next.layout.contains(&panel) || next.hidden.iter().any(|(id, _)| *id == panel) { + let fallback = home_anchor(&next.layout, panel).map(|(anchor, _)| anchor); + next.set_visible(panel, true, fallback, permissions).map_err(|error| error.to_string())?; + } else { + next.apply_all(home(&next.layout, panel).into_iter().chain([Action::Activate { panel }]), permissions).map_err(|error| error.to_string())?; + } validate(&next)?; *workspace = next; Ok(()) @@ -188,7 +281,7 @@ fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> { set_visible(workspace, Panel::Layers, false)?; set_visible(workspace, 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. + // Reopen the palette that closed last before the one recorded beside it. set_visible(workspace, Panel::Properties, true)?; set_visible(workspace, Panel::Layers, true)?; } @@ -207,6 +300,10 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result { - ui.painter().rect_filled(ui.max_rect(), 0.0, t.canvas); - crate::canvas::show(app, ui); + let mut area = DockArea::new(egui::Id::unique("cadcraft-docking")).input_blocked(native_input_blocked(app, ui.ctx())); + if let Some(panel) = app.raise_panel.take() { + area = area.raise_panel(panel); + } + let mut output = area.show_content(ui, &workspace.layout, &style, &mut Panels { app }); + // Keys typed in a floating panel's own OS window run the main window's shortcuts and + // command line; files dropped on one open like files dropped on the main window. + app.forward_native_input(ui.ctx(), std::mem::take(&mut output.forwarded_events), output.forwarded_modifiers); + #[cfg(not(target_arch = "wasm32"))] + for files in std::mem::take(&mut output.window_files) { + for file in files.dropped { + let path = file.path().to_string_lossy().to_string(); + if !path.is_empty() { + app.open_path(&path); } - Panel::ToolSets => crate::palettes::toolsets(app, ui), - Panel::QuickCalc => crate::palettes::quickcalc::show(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::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries) - }); - } - Panel::ExternalRefs => { - egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui)); - } - }, - ); + } + } // Existing panel close buttons and commands still own their visibility switches. let rendered = workspace; let mut workspace = app.ui.docking.clone(); @@ -290,6 +378,64 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { } } +/// Whether the floating panels' own OS windows ignore input: while a modal dialog is up, since +/// egui's `Modal` blocks only the main window. egui's modal layer is last frame's; the app's +/// state covers the frame a modal opens in. +pub fn native_input_blocked(app: &crate::CadApp, ctx: &egui::Context) -> bool { + app.shows_modal() || ctx.memory(|memory| memory.top_modal_layer()).is_some() +} + +/// The panels' contents, names, size limits and icons for the dock area. +pub struct Panels<'a> { + pub app: &'a mut crate::CadApp, +} + +impl DockContent for Panels<'_> { + fn body(&mut self, ui: &mut egui::Ui, panel: &Panel) { + let app = &mut *self.app; + match panel { + Panel::Canvas => { + ui.painter().rect_filled(ui.max_rect(), 0.0, crate::theme::Tokens::get().canvas); + crate::canvas::show(app, ui); + } + Panel::ToolSets => crate::palettes::toolsets(app, ui), + Panel::QuickCalc => crate::palettes::quickcalc::show(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::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries) + }); + } + Panel::ExternalRefs => { + egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui)); + } + } + } + fn logical_label(&self, panel: &Panel) -> String { + crate::i18n::t(panel.label()).into() + } + fn permissions(&self, panel: &Panel) -> Permissions { + permissions(panel) + } + fn limits(&self, panel: &Panel) -> PanelLimits { + panel_limits(panel) + } + fn dock_heights(&self, panel: &Panel) -> Option { + // Layers keeps its list's height above Properties, which takes the rest of the column. + (*panel == Panel::Layers).then(|| DockHeights::new(LAYERS_HEIGHT)) + } + fn paint_icon(&mut self, painter: &egui::Painter, rect: egui::Rect, panel: &Panel, _color: egui::Color32) -> bool { + let Some(icon) = panel.icon() else { return false }; + crate::icons::paint(painter, rect, icon, false); + true + } +} + fn panel_limits(panel: &Panel) -> PanelLimits { let (min, max_width) = match panel { Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 000e158f3..017cf5a42 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -235,6 +235,15 @@ pub struct CadApp { /// Admitted `ui.screenshot` requests, including completed-but-undrained ones. pub(crate) shots: capture::Shots, pub synthetic: Vec, + /// Keys, text and clipboard events a floating panel's own OS window left over, with the + /// modifiers held there, for the main window's next input ([`Self::forward_native_input`]). + forwarded: Vec, + forwarded_modifiers: Option, + /// The main window's own modifiers, put back the frame after forwarded keys went in. + restore_modifiers: Option, + /// A panel opened from a menu or command, whose floating window comes to the front on the + /// next frame (craft-ui's `DockArea::raise_panel`). + pub raise_panel: Option, /// One synthetic batch has been put into `RawInput` and is waiting for a real `App::ui` call. synthetic_injected: bool, /// Counts settled UI passes (one per painted frame; never hidden logic-only ticks or @@ -303,6 +312,10 @@ impl CadApp { capture_identity_epoch: 0, shots: capture::Shots::default(), synthetic: Vec::new(), + forwarded: Vec::new(), + forwarded_modifiers: None, + restore_modifiers: None, + raise_panel: None, synthetic_injected: false, ui_frames: 0, #[cfg(not(target_arch = "wasm32"))] @@ -604,6 +617,9 @@ impl CadApp { if ctx.current_pass_index() != 0 { return; } + if self.restore_modifiers.is_some() { + ctx.request_repaint(); + } // Closing the window with unsaved changes asks first. `run_logic` refreshes viewport // close/focus state, so this must also work while hidden. if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() { @@ -625,13 +641,56 @@ impl CadApp { capture::request_hidden_paint(self, ctx); } - /// Input hook: refresh the raw surface snapshot and release at most one synthetic batch. + /// Queue keys typed in a floating panel's own OS window (craft-ui's + /// `DockOutput::forwarded_events` and `forwarded_modifiers`) for the main window's next + /// input. egui gives each OS window only its own input, and the shortcuts, the command line + /// and the canvas all read the main window's, so the keys go in there as if typed in it. + pub fn forward_native_input(&mut self, ctx: &egui::Context, events: Vec, modifiers: Option) { + if events.is_empty() { + return; + } + self.forwarded.extend(events); + if modifiers.is_some() { + self.forwarded_modifiers = modifiers; + } + if self.restore_modifiers.is_none() { + self.restore_modifiers = Some(ctx.input(|i| i.modifiers)); + } + ctx.request_repaint_of(egui::ViewportId::ROOT); + } + + /// Whether a modal dialog is up: the "Save changes?" prompt or Drawing Recovery. The + /// floating panels' own OS windows take no input meanwhile (egui's `Modal` blocks only the + /// main window). The app's other dialogs are ordinary windows that leave the drawing usable. + pub fn shows_modal(&self) -> bool { + self.close_prompt().is_some() || !self.recovery.items.is_empty() + } + + /// Input hook: refresh the raw surface snapshot, put in keys forwarded from floating panels' + /// OS windows, and release at most one synthetic batch. /// /// Eframe keeps the unused `RawInput` across hidden logic-only ticks, so a batch already in /// the buffer is never injected again; the next batch waits for a real `ui` pass to consume /// the previous one. pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { capture::note_raw_geometry(self, ctx, raw); + // The forwarded window's modifiers hold for the frame its keys go in (Shift+Copy is + // COPYBASE); the next frame puts the main window's back, before its own changes. + if !self.forwarded.is_empty() { + // A modifier the main window changed meanwhile is the one to put back. + if let Some(own) = raw.events.iter().rev().find_map(|event| match event { + egui::Event::ModifiersChanged(modifiers) => Some(*modifiers), + _ => None, + }) { + self.restore_modifiers = Some(own); + } + if let Some(modifiers) = self.forwarded_modifiers.take() { + raw.events.push(egui::Event::ModifiersChanged(modifiers)); + } + raw.events.append(&mut self.forwarded); + } else if let Some(modifiers) = self.restore_modifiers.take() { + raw.events.insert(0, egui::Event::ModifiersChanged(modifiers)); + } if self.synthetic_injected || self.synthetic.is_empty() { return; } diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index f142ab1f2..51a21834f 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -37,19 +37,10 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option Result { - let panel = Panel::ExternalRefs; - let first = !app.ui.docking.layout.contains(&panel) && !app.ui.docking.hidden.iter().any(|(p, _)| *p == panel); - let r = crate::docking::command(app, false, &json!({ "operation": "open", "panel": panel.id() }))?; - if first && app.ui.docking.layout.contains(&Panel::Layers) { - let moved = json!({ "operation": "move", "panel": panel.id(), "target": Panel::Layers.id(), "zone": "bottom" }); - if let Ok(r) = crate::docking::command(app, false, &moved) { - return Ok(r); - } - } - Ok(r) + crate::docking::command(app, false, &json!({ "operation": "open", "panel": Panel::ExternalRefs.id() })) } /// A picked file inside the host drawing's folder is attached by its relative path, so the diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 18410ceae..c457052ff 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -1,7 +1,8 @@ //! 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 cadcraft_ui_egui::{CadApp, Services, UiState, docking::Panel, menus}; +use craft_ui::docking::{Column, Divider, DockArea, Hit, Layout, Node, Side}; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -42,6 +43,110 @@ fn drag(harness: &mut Harness<'_, CadApp>, from: egui::Pos2, to: egui::Pos2) { harness.run_steps(4); } +fn area() -> egui::Id { + egui::Id::unique("cadcraft-docking") +} + +/// The rect the dock area drew last frame for `hit`. +fn hit(h: &Harness<'_, CadApp>, hit: Hit) -> egui::Rect { + let hits = DockArea::::hit_rects(&h.ctx, area()); + hits.iter().find(|(drawn, _)| *drawn == hit).map(|(_, rect)| *rect).unwrap_or_else(|| panic!("{hit:?} is not drawn: {hits:?}")) +} + +/// The columns of the dock on `side`, from the window edge inwards. +fn columns(layout: &Layout, side: Side) -> Vec> { + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Dock { side: s, columns } if *s == side => return columns.clone(), + _ => {} + } + } + Vec::new() +} + +/// The panels of each group of each column of the dock on `side`. +fn dock_panels(layout: &Layout, side: Side) -> Vec>> { + columns(layout, side).iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect() +} + +/// The tab groups outside the docks: the frames of the split tree. +fn frames(layout: &Layout) -> Vec> { + let mut out = Vec::new(); + let mut nodes: Vec<&Node> = layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => nodes.extend([&**first, &**second]), + Node::Tabs { panels, .. } => out.push(panels.clone()), + Node::Stack { entries } => out.push(entries.iter().map(|entry| entry.panel).collect()), + _ => {} + } + } + out +} + +#[test] +fn default_layout_is_tool_sets_left_and_the_palettes_right_in_dock_columns() { + use cadcraft_ui_egui::docking::{PALETTES_WIDTH, TOOL_SETS_WIDTH, default_workspace, validate}; + let workspace = default_workspace(); + validate(&workspace).unwrap(); + assert!(workspace.hidden.is_empty() && workspace.layout.floating.is_empty()); + assert_eq!(frames(&workspace.layout), [[Panel::Canvas]]); + let start = columns(&workspace.layout, Side::Start); + let end = columns(&workspace.layout, Side::End); + assert_eq!(dock_panels(&workspace.layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(dock_panels(&workspace.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + assert_eq!((start[0].width, end[0].width), (TOOL_SETS_WIDTH, PALETTES_WIDTH)); + assert!(start.iter().chain(&end).all(|column| !column.iconic)); + assert!(start.iter().chain(&end).flat_map(|column| &column.groups).all(|group| group.height.is_none() && !group.minimized)); + assert!(!workspace.layout.contains(&Panel::QuickCalc) && !workspace.layout.contains(&Panel::ExternalRefs)); + + // Drawn: Tool Sets left of the drawing, Layers above Properties right of it, Layers at its + // own height and Properties taking the rest of the column. + let h = harness(None, 1280.0, 1.0); + let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); + let tools = hit(&h, Hit::Tab(Panel::ToolSets)); + let layers = hit(&h, Hit::Tab(Panel::Layers)); + let properties = hit(&h, Hit::Tab(Panel::Properties)); + assert!(tools.right() <= drawing.left() + 1.0, "{tools:?} {drawing:?}"); + assert!(layers.left() >= drawing.right() - 1.0 && properties.left() >= drawing.right() - 1.0, "{layers:?} {properties:?} {drawing:?}"); + let seam = hit(&h, Hit::Divider(Divider::Group(Panel::Layers))); + assert!((seam.center().y - layers.bottom() - 280.0).abs() < 8.0, "{layers:?} {seam:?}"); + assert!(properties.top() > seam.top()); +} + +#[test] +fn palette_icons_are_painted_in_code_and_shown_in_an_iconic_column() { + use cadcraft_ui_egui::docking::Panels; + let mut app = CadApp::new(Session::default(), Services::default()); + let ctx = egui::Context::default(); + let mut paint = |panel: Panel| { + let mut painted = false; + let mut content = Panels { app: &mut app }; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(20.0, 20.0)); + painted = craft_ui::docking::DockContent::paint_icon(&mut content, &painter, rect, &panel, egui::Color32::RED); + }); + output.textures_delta.clear(); + (painted, output.shapes.len()) + }; + for panel in [Panel::ToolSets, Panel::QuickCalc, Panel::Layers, Panel::Properties, Panel::ExternalRefs] { + let (painted, shapes) = paint(panel); + assert!(painted && shapes > 0, "{panel:?}: {painted} {shapes}"); + } + assert_eq!(paint(Panel::Canvas), (false, 0)); + + let mut h = harness(None, 1280.0, 1.0); + h.state_mut().run("ui.dock", json!({"action":{"SetIconic":{"panel":"layers","iconic":true}}})).unwrap(); + h.run_steps(4); + let drawing = h.state().canvas.rect.unwrap(); + let layers = hit(&h, Hit::Icon(Panel::Layers)); + let properties = hit(&h, Hit::Icon(Panel::Properties)); + assert!(layers.left() >= drawing.right() - 1.0 && layers.top() < properties.top(), "{layers:?} {properties:?}"); +} + #[test] fn real_panel_drag_float_close_reopen_and_full_workspace_reload() { let mut harness = harness(None, 1280.0, 1.0); @@ -94,9 +199,11 @@ fn invalid_requests_leave_document_and_panel_layout_unchanged() { json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"right", "before":null}), json!({"operation":"spin", "panel":"layers"}), json!({"action":{"Float":{"panel":"canvas","rect":[0,0,100,100]}}}), + json!({"action":{"MovePanel":{"panel":"canvas","to":{"NewDock":{"side":"Start"}}}}}), + json!({"action":{"Move":{"panel":"layers","anchor":"canvas","placement":{"Tab":{"before":null}}}}}), json!(null), ] { - assert!(harness.state_mut().run("ui.dock", params).is_err()); + assert!(harness.state_mut().run("ui.dock", params.clone()).is_err(), "{params}"); assert_eq!(serde_json::to_value(&harness.state().ui.docking).unwrap(), before); assert_eq!(harness.state().session.doc().unwrap(), &drawing); } @@ -131,48 +238,39 @@ fn docking_renders_default_float_and_redocked_at_normal_and_narrow_sizes() { } #[test] -fn automation_reaches_split_stack_resize_and_tab_reorder_through_the_same_handler() { - use craft_ui::{docking::Action, layout::SplitSize}; +fn automation_reaches_column_and_group_resizes_tab_reorder_and_minimize_through_the_same_handler() { + use craft_ui::docking::Action; 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)))); + let act = |h: &mut Harness<'_, CadApp>, action: Action| { + h.state_mut().run("ui.dock", json!({"action": serde_json::to_value(action).unwrap()})).unwrap(); + }; + act(&mut h, Action::ResizeColumn { panel: Panel::ToolSets, width: 280.0 }); + act(&mut h, Action::ResizeGroup { panel: Panel::Layers, height: Some(160.0) }); + let layout = &h.state().ui.docking.layout; + assert_eq!(columns(layout, Side::Start)[0].width, 280.0); + assert_eq!(columns(layout, Side::End)[0].groups[0].height, Some(160.0)); h.state_mut().run("ui.dock", json!({"operation":"move","panel":"layers","target":"properties","zone":"center","before":null})).unwrap(); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [[[Panel::Properties, Panel::Layers]]]); + act(&mut h, Action::SetMinimized { panel: Panel::Layers, minimized: true }); 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))); + assert!(columns(&h.state().ui.docking.layout, Side::End)[0].groups[0].minimized); + assert_eq!(columns(&h.state().ui.docking.layout, Side::Start)[0].width, 280.0); } #[test] -fn stack_boundary_resizes_by_pointer_and_keyboard_without_changing_the_drawing() { +fn group_divider_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(); + let layers_height = |h: &Harness<'_, CadApp>| columns(&h.state().ui.docking.layout, Side::End)[0].groups[0].height; + let center = hit(&h, Hit::Divider(Divider::Group(Panel::Layers))).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(); + let height = layers_height(&h).expect("the drag sets the Layers group's height"); + h.ctx.memory_mut(|memory| memory.request_focus(DockArea::::hit_id(area(), &Hit::Divider(Divider::Group(Panel::Layers))))); 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"); + let after = layers_height(&h).unwrap(); + assert!(after > height, "keyboard resize must reach the same panel command handler: {height} {after}"); assert_eq!(h.state().session.doc().unwrap(), &drawing); } @@ -219,6 +317,67 @@ fn palettes_off_and_on_reopen_a_floating_group_as_one_window() { assert!(floating[0].contains(&Panel::Layers) && floating[0].contains(&Panel::Properties), "{floating:?}"); } +#[test] +fn palettes_off_and_on_restore_the_right_dock_column() { + let mut h = harness(None, 1280.0, 1.0); + let before = h.state().ui.docking.layout.clone(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + assert!(!h.state().ui.docking.layout.contains(&Panel::Layers) && !h.state().ui.docking.layout.contains(&Panel::Properties)); + h.state_mut().run("ui.toggle.palettes", json!({"on": true})).unwrap(); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::End), dock_panels(&before, Side::End)); + assert_eq!(columns(layout, Side::End)[0].width, columns(&before, Side::End)[0].width); +} + +#[test] +fn window_menu_reopens_closed_and_new_palettes_in_their_dock_groups() { + use cadcraft_ui_egui::docking::{PALETTES_WIDTH, TOOL_SETS_WIDTH}; + let mut h = harness(None, 1280.0, 1.0); + // A closed palette comes back where it was. + h.state_mut().run("ui.dock", json!({"operation":"close","panel":"layers"})).unwrap(); + h.run_steps(3); + menus::activate(h.state_mut(), "ui.panel.layers"); + h.run_steps(3); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + // Tool Sets turned off and on in the Window menu comes back in the left dock at its width. + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + assert!(!h.state().ui.docking.layout.contains(&Panel::ToolSets)); + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(columns(layout, Side::Start)[0].width, TOOL_SETS_WIDTH); + // After Reset nothing has a recorded place: External References opens below Properties. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("xref", serde_json::Value::Null).unwrap(); + h.run_steps(3); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties], vec![Panel::ExternalRefs]]]); + // Tool Sets with no recorded place and no left dock makes one at its width. + let mut workspace = cadcraft_ui_egui::docking::default_workspace(); + workspace.apply(craft_ui::docking::Action::Close { panel: Panel::ToolSets }, cadcraft_ui_egui::docking::permissions).unwrap(); + workspace.hidden.clear(); + h.state_mut().ui.docking = workspace; + h.state_mut().ui.show_toolsets = false; + menus::activate(h.state_mut(), "ui.toggle.toolsets"); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::ToolSets]]]); + assert_eq!(columns(layout, Side::Start)[0].width, TOOL_SETS_WIDTH); + // With the palettes off, External References makes a new right dock at the palettes' width. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("ui.toggle.palettes", json!({"on": false})).unwrap(); + h.run_steps(3); + h.state_mut().run("xref", serde_json::Value::Null).unwrap(); + h.run_steps(3); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::End), [[[Panel::ExternalRefs]]]); + assert_eq!(columns(layout, Side::End)[0].width, PALETTES_WIDTH); + assert_eq!(frames(layout), [[Panel::Canvas]]); +} + #[test] fn explicit_properties_open_restores_closed_panel_and_activates_inactive_tab() { let mut harness = harness(None, 1000.0, 1.0); @@ -276,15 +435,8 @@ fn properties_typed_in_rendered_command_line_reopens_and_selects_actual_workspac 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)); + assert_eq!(dock_panels(&h.state().ui.docking.layout, Side::End), [vec![vec![Panel::Layers], vec![Panel::Properties]]]); + assert_eq!(h.state().ui.docking.layout.location(&Panel::ToolSets).unwrap(), tools); 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(); @@ -310,34 +462,27 @@ fn reset_typed_in_canvas_preserves_authoritative_workspace_replacement() { } #[test] -fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { +fn canvas_reset_during_column_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. + // Give the Tool Sets column a width different from Reset's. h.state_mut() - .run( - "ui.dock", - json!({"action":Action::::ResizeSplit { - path: Vec::new(), size: SplitSize::FixedFirst(280.0), - }}), - ) + .run("ui.dock", json!({"action": serde_json::to_value(Action::ResizeColumn { panel: Panel::ToolSets, width: 280.0 }).unwrap()})) .unwrap(); h.run_steps(3); let drawing = h.state().session.doc().unwrap().clone(); - let splitter = egui::Id::unique("cadcraft-docking").with(("split", Vec::::new())); - let from = h.ctx.read_response(splitter).expect("rendered root divider").rect.center(); + let divider = Hit::Divider(Divider::Column(Panel::ToolSets)); + let handle = DockArea::::hit_id(area(), ÷r); + let from = hit(&h, divider).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_eq!(h.ctx.dragged_id(), Some(handle), "the column's 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)); + // A divider 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(handle)); assert!(!h.ctx.egui_wants_keyboard_input()); let to = from + vec2(36.0, 0.0); h.event(Event::PointerMoved(to)); @@ -346,10 +491,10 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { h.run_steps(1); let expected = cadcraft_ui_egui::docking::default_workspace(); 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.layout, expected.layout, "old-frame resize must not modify Reset's layout"); 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. + // Keep the physical button down: replacement must end the old divider gesture rather than + // letting it go on resizing the new workspace. let later = to + vec2(24.0, 0.0); h.event(Event::PointerMoved(later)); h.run_steps(2); @@ -386,35 +531,65 @@ fn collapsed_tool_sets_leave_a_visible_control_that_expands_them() { } #[test] -fn group_drag_bar_moves_the_whole_group_beside_the_drawing() { - use craft_ui::docking::{DockArea, Hit, Node}; +fn palettes_dock_only_in_the_left_and_right_columns() { + let mut h = harness(None, 1280.0, 1.0); + let document = h.state().session.doc().unwrap().clone(); + let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); + // Neither the drawing nor the bottom edge takes a drop: the palette floats there. + for to in [drawing.center(), egui::pos2(drawing.center().x, drawing.bottom() - 4.0)] { + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.run_steps(3); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, to); + let layout = &h.state().ui.docking.layout; + assert_eq!(frames(layout), [[Panel::Canvas]], "{to:?}: {:?}", layout.root); + assert_eq!(dock_panels(layout, Side::End), [[[Panel::Properties]]], "{to:?}"); + assert!(layout.floating.iter().any(|window| window.contains(&Panel::Layers)), "{to:?}: {layout:?}"); + } + // The left window edge, where Tool Sets docks, takes the palette as a new outer column. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.run_steps(3); + let column = hit(&h, Hit::ColumnBar(Panel::ToolSets)); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, egui::pos2(column.left() + 3.0, drawing.center().y)); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::Layers]], [[Panel::ToolSets]]], "{:?}", layout.root); + assert_eq!(frames(layout), [[Panel::Canvas]]); + // Without a left dock, the left window edge makes one. + h.state_mut().run("ui.dock.reset", json!({})).unwrap(); + h.state_mut().run("ui.toggle.toolsets", json!({"on": false})).unwrap(); + h.run_steps(4); + assert!(columns(&h.state().ui.docking.layout, Side::Start).iter().all(|column| column.groups.is_empty())); + let drawing = h.state().canvas.rect.unwrap(); + let from = hit(&h, Hit::Tab(Panel::Layers)).center(); + drag(&mut h, from, egui::pos2(drawing.left() + 3.0, drawing.center().y)); + let layout = &h.state().ui.docking.layout; + assert_eq!(dock_panels(layout, Side::Start), [[[Panel::Layers]]], "{:?}", layout.root); + assert_eq!(frames(layout), [[Panel::Canvas]]); + assert_eq!(h.state().session.doc().unwrap(), &document); +} + +#[test] +fn group_drag_bar_moves_the_whole_group_into_the_other_dock() { let mut h = harness(None, 1280.0, 1.0); h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap(); h.run_steps(4); - let bar = |h: &Harness<'_, CadApp>| { - DockArea::::hit_rects(&h.ctx, egui::Id::unique("cadcraft-docking")) - .into_iter() - .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) - .map(|(_, rect)| rect) - }; - let from = bar(&h).expect("the Tool Sets group draws a drag bar").center(); - let drawing = h.state().canvas.rect.expect("the drawing view is drawn"); - let to = egui::pos2(drawing.right() - 10.0, drawing.center().y); + let bar = DockArea::::hit_rects(&h.ctx, area()) + .into_iter() + .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) + .map(|(_, rect)| rect) + .expect("the Tool Sets group draws a drag bar"); + // Into the middle of the Properties group, as tabs. + let properties = hit(&h, Hit::Tab(Panel::Properties)); + let column = hit(&h, Hit::ColumnBar(Panel::Properties)); + let to = egui::pos2(column.center().x, properties.bottom() + 120.0); let document = h.state().session.doc().unwrap().clone(); - drag(&mut h, from, to); + drag(&mut h, bar.center(), to); let layout = &h.state().ui.docking.layout; - assert!(layout.floating.is_empty(), "a drop beside the drawing docks the group"); - // The drawing's frame splits: the drawing on the left, the whole group on the right. - let mut nodes: Vec<&Node> = layout.root.iter().collect(); - let mut beside = false; - while let Some(node) = nodes.pop() { - if let Node::Split { first, second, .. } = node { - beside |= matches!((&**first, &**second), (Node::Tabs { panels: left, .. }, Node::Tabs { panels: right, .. }) - if left == &[Panel::Canvas] && right == &[Panel::ToolSets, Panel::Layers]); - nodes.push(first); - nodes.push(second); - } - } - assert!(beside, "the group moves as one to the drawing's right: {:?}", layout.root); + assert!(layout.floating.is_empty(), "{layout:?}"); + let end = dock_panels(layout, Side::End); + assert_eq!(end.len(), 1, "{end:?}"); + assert!(end[0].iter().any(|group| group.contains(&Panel::ToolSets) && group.contains(&Panel::Layers)), "{end:?}"); + assert_eq!(frames(layout), [[Panel::Canvas]]); assert_eq!(h.state().session.doc().unwrap(), &document); } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index c908bc9e2..92554f7dd 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -18,6 +18,7 @@ mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; mod native_menu_text_keys; +mod native_windows; mod options_dialog; mod parameters_rename; mod pickdrag; diff --git a/crates/ui-egui/tests/it/native_windows.rs b/crates/ui-egui/tests/it/native_windows.rs new file mode 100644 index 000000000..e98825a80 --- /dev/null +++ b/crates/ui-egui/tests/it/native_windows.rs @@ -0,0 +1,116 @@ +//! Floating panels in their own OS windows: a modal dialog blocks their input, and keys typed in +//! one run the main window's shortcuts and command line. + +use cadcraft_engine::Session; +use cadcraft_engine::autosave::Recoverable; +use cadcraft_ui_egui::closing::Choice; +use cadcraft_ui_egui::docking::native_input_blocked; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Event, Key, Modifiers}; +use serde_json::json; + +fn frame(app: &mut CadApp, ctx: &egui::Context) { + frame_with(app, ctx, Vec::new()); +} + +/// A frame whose main-window input carries `events`. +fn frame_with(app: &mut CadApp, ctx: &egui::Context, events: Vec) { + let mut raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), events, ..Default::default() }; + app.raw_input_hook(ctx, &mut raw); + cadcraft_ui_egui::cmdline::capture_tab(ctx, &mut raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); +} + +fn pump(app: &mut CadApp, ctx: &egui::Context) { + for _ in 0..3 { + frame(app, ctx); + } +} + +fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn running(app: &CadApp) -> Option<&str> { + app.session.running.as_ref().map(|command| command.id.as_str()) +} + +#[test] +fn modal_dialogs_block_native_panel_windows() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx)); + + // "Save changes?" while a dirty drawing closes. + app.run("circle", json!({"center": [0, 0], "radius": 1})).unwrap(); + app.request_close(None); + assert!(native_input_blocked(&app, &ctx), "close prompt"); + pump(&mut app, &ctx); + assert!(native_input_blocked(&app, &ctx), "close prompt shown"); + app.answer_close(Choice::Cancel); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx), "after Cancel"); + + // Drawing Recovery. + app.recovery.items.push(Recoverable { path: "missing.sv$".into(), drawing: "Plan.dxf".into(), modified: 0, bytes: 0 }); + assert!(native_input_blocked(&app, &ctx), "drawing recovery"); + app.recovery.items.clear(); + pump(&mut app, &ctx); + assert!(!native_input_blocked(&app, &ctx)); + + // Any other egui modal, from the frame after it is shown. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Modal::new(egui::Id::unique("test_modal")).show(ui.ctx(), |ui| ui.label("modal")); + }); + out.textures_delta.clear(); + assert!(native_input_blocked(&app, &ctx), "egui modal"); +} + +#[test] +fn keys_forwarded_from_a_native_window_reach_the_command_line_and_shortcuts() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + + let typed = vec![Event::Text("LINE".into()), key(Key::Enter, true, Modifiers::NONE), key(Key::Enter, false, Modifiers::NONE)]; + app.forward_native_input(&ctx, typed, Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(running(&app), Some("line")); + assert_eq!(app.cmd.history, ["LINE"]); + + let escape = vec![key(Key::Escape, true, Modifiers::NONE), key(Key::Escape, false, Modifiers::NONE)]; + app.forward_native_input(&ctx, escape, Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(running(&app), None); + + let ortho = app.session.settings.orthomode; + app.forward_native_input(&ctx, vec![key(Key::F8, true, Modifiers::NONE), key(Key::F8, false, Modifiers::NONE)], Some(Modifiers::NONE)); + pump(&mut app, &ctx); + assert_eq!(app.session.settings.orthomode, !ortho, "F8"); + + // Shift held in the native window: Cmd/Ctrl+Shift+C arrives as Copy and runs COPYBASE. The + // main window's own modifiers come back afterwards. + app.forward_native_input(&ctx, vec![Event::Copy], Some(Modifiers::SHIFT | Modifiers::COMMAND)); + pump(&mut app, &ctx); + assert_eq!(running(&app), Some("copybase")); + assert_eq!(ctx.input(|i| i.modifiers), Modifiers::NONE); +} + +#[test] +fn a_main_window_modifier_change_outlasts_keys_forwarded_meanwhile() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + pump(&mut app, &ctx); + frame_with(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::SHIFT)]); + app.forward_native_input(&ctx, vec![key(Key::F8, true, Modifiers::COMMAND), key(Key::F8, false, Modifiers::COMMAND)], Some(Modifiers::COMMAND)); + // Shift is released in the main window in the frame the forwarded keys go in. + frame_with(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::NONE)]); + pump(&mut app, &ctx); + assert_eq!(ctx.input(|i| i.modifiers), Modifiers::NONE, "the released Shift is not put back"); +}