diff --git a/Cargo.lock b/Cargo.lock index 31abb19c..700f386f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -988,6 +988,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "combine" version = "4.6.8" @@ -1062,6 +1072,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1202,6 +1221,19 @@ dependencies = [ "thousands", ] +[[package]] +name = "dify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ce0fb972943b4e88cd03b8f92953df0c71bb05e0bde8e5b684895d808013cc" +dependencies = [ + "anyhow", + "colored", + "getopts", + "image", + "rayon", +] + [[package]] name = "digest" version = "0.10.7" @@ -1447,6 +1479,27 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_kittest" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +dependencies = [ + "dify", + "eframe", + "egui", + "egui-wgpu", + "image", + "kittest", + "log", + "open", + "pollster 1.0.1", + "serde", + "tempfile", + "toml", + "wgpu", +] + [[package]] name = "either" version = "1.18.0" @@ -2230,6 +2283,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.2.17" @@ -2588,6 +2650,25 @@ dependencies = [ "syn 2.0.119", ] +[[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.14.0" @@ -2909,6 +2990,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" @@ -3343,7 +3434,9 @@ dependencies = [ name = "lightcraft-ui-egui" version = "0.6.0" dependencies = [ + "craft-ui", "egui", + "egui_kittest", "lightcraft-catalog", "lightcraft-codecs", "lightcraft-color", @@ -4169,6 +4262,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[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 08e0ac51..b9a511e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ rust-version = "1.90" repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } diff --git a/README.md b/README.md index cc1fd85f..1a7ca07f 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,8 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det ## Quick start +Workspace builds, including the desktop, web and CLI applications, require Rust 1.95 or newer. + ```sh git clone https://github.com/storytold/lightcraft && cd lightcraft cargo run --release -p lightcraft # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos) @@ -571,3 +573,5 @@ Forks and modified versions must remove them. ## Star history [![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left) + +Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. 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 c1cfbcf5..c8062d53 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -114,6 +114,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- **2026-10-10 (UI docking):** Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Widget state styling uses the shared `craft-ui` theme helper, retaining the dark palette, typography, spacing and per-state borders. No readiness percentage change. - 2026-10-10 (RAW): RAW measured model by model against Adobe's 1,446-model list (234 verified, 943 unverified, 233 preview-only, 36 unsupported; ~84% of photographers' cameras decode, ~40% on a verified body). Full ~61%, mainstream ~49%, essentials ~64%; beta ~14 points away. diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 00665e87..09cb1434 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -183,6 +183,23 @@ fn load_prefs(in_memory: bool) -> (Option, Option, bool) { load_prefs_at(&path, !in_memory) } +/// Compatibility recovery is limited to persisted docking, not runtime UiState commands. +fn decode_saved_ui(bytes: &[u8]) -> Result<(UiState, bool), String> { + let mut saved: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; + let fields = saved.as_object_mut().ok_or("settings must be an object")?; + let recovered = + fields.get("docking").is_some_and(|value| match serde_json::from_value::>(value.clone()) { + Ok(Some(workspace)) => workspace.validate().is_err(), + Ok(None) => false, + Err(_) => true, + }); + if recovered { + fields.remove("docking"); + } + let ui = serde_json::from_value(saved).map_err(|error| error.to_string())?; + Ok((ui, recovered)) +} + fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option, Option, bool) { let bytes = match std::fs::read(path) { Ok(b) => b, @@ -192,8 +209,15 @@ fn load_prefs_at(path: &std::path::Path, set_aside_damaged: bool) -> (Option(&bytes) { - Ok(ui) => (Some(ui.sanitized()), None, false), + match decode_saved_ui(&bytes) { + Ok((ui, false)) => (Some(ui.sanitized()), None, false), + Ok((ui, true)) => { + let msg = format!( + "The saved panel layout in {} couldn't be restored. Other settings were loaded; the original file is preserved and settings won't be saved this session.", + path.display() + ); + (Some(ui.sanitized()), Some(msg), true) + } Err(e) if !set_aside_damaged => { let msg = format!("The app settings ({}) are damaged ({e}). Defaults are used; nothing is saved in this session.", path.display()); (None, Some(msg), true) @@ -270,6 +294,9 @@ impl PrefsWriter { return Ok(()); } let Some(path) = self.path.clone() else { return Ok(()) }; + if let Some(workspace) = &ui.docking { + workspace.validate()?; + } let bytes = serde_json::to_vec_pretty(ui).map_err(|e| e.to_string())?; if bytes == self.written { return Ok(()); @@ -608,6 +635,7 @@ OPTIONS: --version, --help ENVIRONMENT: + LIGHTCRAFT_CONFIG_DIR=DIR use this settings folder instead of the platform default (empty = no settings folder) LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS, Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering. LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: /models/sam3; @@ -663,10 +691,16 @@ fn control_port_from(what: &str, value: Option) -> Option { port } +/// An isolated profile keeps the emergency crash log with its other app data. With no +/// override (or an empty override), retain the historical system-temp location. +fn panic_log_path_with(env: impl Fn(&str) -> Option, temp: std::path::PathBuf) -> std::path::PathBuf { + env("LIGHTCRAFT_CONFIG_DIR").filter(|value| !value.is_empty()).map(std::path::PathBuf::from).unwrap_or(temp).join("lightcraft-panics.log") +} + fn main() -> eframe::Result { // First, so every start-up record is kept for the log file (`logging`). let logger = logging::install(); - lightcraft_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log")); + lightcraft_engine::guard::install_hook(panic_log_path_with(|name| std::env::var_os(name), std::env::temp_dir())); if let Some(logger) = logger { logging::record_panics(logger); } @@ -904,6 +938,59 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + #[test] + fn docking_survives_native_settings_file_reload() { + use lightcraft_ui_egui::docking::Panel; + let d = dir("docking-reload"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.run("ui.dock", serde_json::json!({"operation":"float", "panel":"tools", "rect":[70,80,310,500]})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"close", "panel":"tools"})).unwrap(); + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + writer.save_ui(&app.ui, false).unwrap(); + let (restored, warning, keep) = load_prefs_at(&path, false); + assert!(warning.is_none()); + assert!(!keep); + let mut restored_app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + restored_app.ui = restored.unwrap(); + restored_app.run("ui.dock", serde_json::json!({"operation":"open", "panel":"tools"})).unwrap(); + assert_eq!( + restored_app.ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, + [70.0, 80.0, 310.0, 500.0] + ); + std::fs::remove_dir_all(d).unwrap(); + } + + #[test] + fn future_docking_preserves_library_settings_and_the_original_preferences_file() { + let d = dir("future-docking"); + let path = d.join("ui.json"); + let mut ui = UiState { thumb_size: 210.0, filmstrip: false, ..Default::default() }; + ui.settings.library_path = "/Volumes/Photos/Synthetic Library".into(); + for docking in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] { + let mut saved = serde_json::to_value(&ui).unwrap(); + saved["docking"] = docking; + assert!(serde_json::from_value::(saved.clone()).is_err(), "runtime state parsing stays strict"); + let bytes = serde_json::to_vec(&saved).unwrap(); + for set_aside in [false, true] { + std::fs::write(&path, &bytes).unwrap(); + let (loaded, warning, keep) = load_prefs_at(&path, set_aside); + let loaded = loaded.unwrap(); + assert_eq!(loaded.settings.library_path, ui.settings.library_path); + assert_eq!(loaded.thumb_size, ui.thumb_size); + assert!(!loaded.filmstrip); + assert!(loaded.docking.is_none()); + assert!(warning.unwrap().contains("Other settings were loaded")); + assert!(keep); + let mut writer = PrefsWriter { path: Some(path.clone()), keep_file: keep, ..Default::default() }; + writer.save_ui(&loaded, false).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "a future layout does not rename or replace the settings file"); + } + } + std::fs::remove_dir_all(d).unwrap(); + } + /// Issue #164: the writer saves changed settings to its file — and nothing at all without one /// (`--memory`) or while the session is the temporary one offered when the library can't open. #[test] @@ -1056,3 +1143,16 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } } + +#[cfg(test)] +mod config_path_tests { + use super::*; + + #[test] + fn config_override_keeps_panic_log_in_isolated_profile() { + let temp = std::path::PathBuf::from("temp"); + assert_eq!(panic_log_path_with(|_| Some("isolated".into()), temp.clone()), std::path::PathBuf::from("isolated/lightcraft-panics.log")); + assert_eq!(panic_log_path_with(|_| None, temp.clone()), temp.join("lightcraft-panics.log")); + assert_eq!(panic_log_path_with(|_| Some("".into()), temp.clone()), temp.join("lightcraft-panics.log")); + } +} diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index be1fd867..f17f12e0 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -363,7 +363,7 @@ pub fn rule_date(s: &str) -> Result { _ => false, } }; - if !shaped(&s) && !(s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) { + if !(shaped(&s) || s.get(..19).is_some_and(shaped) && s.get(19..).is_some_and(tail_ok)) { return Err(Issue::NeedsDate); } let part = |a: usize, b: usize| s.get(a..b).and_then(|t| t.parse::().ok()); diff --git a/crates/codecs/src/gainmap.rs b/crates/codecs/src/gainmap.rs index 13c3c0e5..c3ab2c6d 100644 --- a/crates/codecs/src/gainmap.rs +++ b/crates/codecs/src/gainmap.rs @@ -411,7 +411,7 @@ pub fn compute( if n == 0 || sdr.len() < n || hdr.len() < n { return Err(Error::Encode("gain map: SDR and HDR renditions must match the image size".into())); } - if !matches!(o.scale, 1 | 2 | 4) || !(o.gamma > 0.0 && o.gamma.is_finite()) || !(o.offset > 0.0 && o.offset.is_finite()) { + if !(matches!(o.scale, 1 | 2 | 4) && o.gamma > 0.0 && o.gamma.is_finite() && o.offset > 0.0 && o.offset.is_finite()) { return Err(Error::Encode("gain map: scale must be 1, 2 or 4, gamma and offset positive".into())); } let s = o.scale; diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index 7ab9b653..2a4ccb6b 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -6,21 +6,42 @@ //! face- or denoise-models folder until a host asks for one ([`Session::with_default_face_models`], //! [`Session::with_default_denoise_models`]), so tests stay hermetic. +use std::ffi::OsString; use std::path::PathBuf; use crate::Session; -/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where. +#[derive(Clone, Copy)] +enum Platform { + Mac, + Windows, + Other, +} + +/// LightCraft's settings folder. A nonempty `$LIGHTCRAFT_CONFIG_DIR` overrides the platform default. +/// The override is the complete directory; no application-name suffix is appended. An explicitly +/// empty value disables the settings directory instead of falling back to a personal profile. pub fn config_dir() -> Option { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")) + let platform = if cfg!(target_os = "macos") { + Platform::Mac } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")) + Platform::Windows } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - .map(|c| c.join("lightcraft")) + Platform::Other + }; + config_dir_with(platform, |name| std::env::var_os(name)) +} + +fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -> Option { + if let Some(dir) = env("LIGHTCRAFT_CONFIG_DIR") { + return (!dir.is_empty()).then(|| PathBuf::from(dir)); + } + match platform { + Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")), + Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")), + Platform::Other => { + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("lightcraft")) + } } } @@ -56,3 +77,56 @@ impl Session { self.denoise.touch(); } } + +#[cfg(test)] +mod config_tests { + use super::*; + + fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option { + config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value)))) + } + + #[test] + fn config_override_wins_without_reading_user_profile() { + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + let actual = config_dir_with(platform, |name| { + assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR"); + Some(OsString::from("isolated/settings")) + }); + assert_eq!(actual, Some(PathBuf::from("isolated/settings"))); + } + } + + #[test] + fn config_empty_override_disables_profile_without_reading_defaults() { + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + assert_eq!( + config_dir_with(platform, |name| { + assert_eq!(name, "LIGHTCRAFT_CONFIG_DIR"); + Some(OsString::new()) + }), + None + ); + } + } + + #[test] + fn config_unset_override_keeps_platform_defaults() { + let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")]; + assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/LightCraft"))); + assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/LightCraft"))); + assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/lightcraft"))); + assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/lightcraft"))); + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + assert_eq!(resolve(platform, &[]), None); + } + } + + #[cfg(unix)] + #[test] + fn config_override_preserves_non_unicode_paths() { + use std::os::unix::ffi::OsStringExt as _; + let value = OsString::from_vec(b"isolated/\xff".to_vec()); + assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value))); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index f7dbc65a..74bc8005 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -2,6 +2,7 @@ name = "lightcraft-ui-egui" version.workspace = true edition.workspace = true +rust-version = "1.95" license.workspace = true description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)" @@ -9,6 +10,7 @@ description = "LightCraft egui frontend (Lightroom-style UI); swappable — noth workspace = true [dependencies] +craft-ui = { workspace = true } lightcraft-geom = { workspace = true } lightcraft-meta = { workspace = true } lightcraft-color = { workspace = true } @@ -25,6 +27,7 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } lightcraft-faces = { workspace = true } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa24e0f1..6520fb3e 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -94,6 +94,7 @@ "Aqua Hue": "Aquamarin – Farbton", "Aqua Luminance": "Aquamarin – Luminanz", "Aqua Saturation": "Aquamarin – Sättigung", + "Arrange Panel": "Bereich anordnen", "ArtCraft Website": "ArtCraft-Website", "ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps", "As Shot": "Wie Aufnahme", @@ -251,6 +252,7 @@ "Close Window": "Fenster schließen", "Close all": "Alle schließen", "Close file": "Datei schließen", + "Close panel": "Bereich schließen", "Cloudy": "Bewölkt", "Cmd+Shift+R, keeps the photo and clears its edits.)": "mit ⌘⇧R die Bearbeitungen zurück und behält das Foto.", "Collapse": "Einklappen", @@ -556,6 +558,7 @@ "Flip Overlay (⇧O)": "Überlagerung spiegeln (⇧O)", "Flip V": "V spiegeln", "Flip Vertical": "Vertikal spiegeln", + "Float panel": "Bereich frei platzieren", "Flow": "Fluss", "Fluorescent": "Leuchtstofflicht", "Focal Length": "Brennweite", @@ -885,6 +888,7 @@ "Move them": "Verschieben", "Move to": "Verschieben nach", "Move to Group": "In Gruppe verschieben", + "Move to group": "In Gruppe verschieben", "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Verschieben: Die Dateien werden in den folgenden Ordner verschoben; jedes Original (und seine XMP-Begleitdatei) wird erst nach Überprüfung der Kopie aus der Quelle entfernt. Duplikate und fehlgeschlagene Dateien bleiben an ihrem Speicherort.", "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Verschiebt alle von diesem Datenträger importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert", "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Verschiebt die aus diesem Ordner importierten Fotos nach „Zuletzt gelöscht“; keine Datei wird verändert", @@ -1005,6 +1009,7 @@ "Paint over a distraction on the photo to remove it.": "Übermale ein störendes Element im Foto, um es zu entfernen.", "Paint overlay": "Überlagerung auftragen", "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "RAW-Dateien von Panasonic und Leica, RW2, RWL und ältere RAW-Dateien von DMC-LX1 bis DC-S1R II, werden in", + "Panels": "Bereiche", "Panorama": "Panorama", "Panorama Merge": "Zu Panorama zusammenfügen", "Panorama Merge Preview": "Panoramavorschau", @@ -1225,6 +1230,7 @@ "Reset Control": "Regler zurücksetzen", "Reset Crop": "Zuschnitt zurücksetzen", "Reset Edits": "Bearbeitungen zurücksetzen", + "Reset Panel Layout": "Bereichsanordnung zurücksetzen", "Reset Parametric Curve": "Parametrische Kurve zurücksetzen", "Reset Section": "Bereich zurücksetzen", "Reset Tone Curve": "Gradationskurve zurücksetzen", @@ -1232,6 +1238,8 @@ "Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)", "Reshuffle": "Neu mischen", "Resize Face Box": "Gesichtsrahmengröße ändern", + "Resize panel window": "Größe des Bereichsfensters ändern", + "Resize panels": "Bereichsgrößen ändern", "Resize to": "Größe ändern auf", "Resolution (ppi)": "Auflösung (ppi)", "Restore": "Wiederherstellen", diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c4f2e835..fc53b639 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -79,6 +79,7 @@ "Aqua Hue": "Tono aguamarina", "Aqua Luminance": "Luminancia aguamarina", "Aqua Saturation": "Saturación aguamarina", + "Arrange Panel": "Organizar panel", "ArtCraft Website": "Sitio web de ArtCraft", "ArtCraft — more creative apps": "ArtCraft: más aplicaciones creativas", "As Shot": "Como se tomó", @@ -231,6 +232,7 @@ "Close Window": "Cerrar ventana", "Close all": "Cerrar todo", "Close file": "Cerrar archivo", + "Close panel": "Cerrar panel", "Cloudy": "Nublado", "Collapse": "Contraer", "Collapse All Stacks": "Contraer todas las pilas", @@ -523,6 +525,7 @@ "Flip Overlay (⇧O)": "Voltear superposición (⇧O)", "Flip V": "Voltear V", "Flip Vertical": "Voltear verticalmente", + "Float panel": "Hacer flotante el panel", "Flow": "Flujo", "Fluorescent": "Fluorescente", "Focal Length": "Distancia focal", @@ -844,6 +847,7 @@ "Move them": "Moverlas", "Move to": "Mover a", "Move to Group": "Mover a un grupo", + "Move to group": "Mover al grupo", "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Mover: los archivos se mueven a la carpeta de abajo; cada original (y su archivo XMP complementario) se quita del origen solo después de verificar su copia. Los duplicados y los archivos que fallan quedan donde están.", "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Mueve todas las fotos importadas desde este disco a Eliminadas recientemente; no se toca ningún archivo", "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Mueve las fotos importadas desde esta carpeta a Eliminadas recientemente; no se toca ningún archivo", @@ -961,6 +965,7 @@ "Paint only areas like the one under the brush": "Pintar solo zonas similares a la que está bajo el pincel", "Paint over a distraction on the photo to remove it.": "Pintar sobre un elemento que distrae en la foto para eliminarlo.", "Paint overlay": "Superposición de pintura", + "Panels": "Paneles", "Panorama": "Panorámica", "Panorama Merge": "Combinación de panorámica", "Panorama Merge Preview": "Vista previa de combinación de panorámica", @@ -1174,6 +1179,7 @@ "Reset Control": "Restablecer control", "Reset Crop": "Restablecer recorte", "Reset Edits": "Restablecer ediciones", + "Reset Panel Layout": "Restablecer disposición de paneles", "Reset Parametric Curve": "Restablecer curva paramétrica", "Reset Section": "Restablecer sección", "Reset Tone Curve": "Restablecer curva de tonos", @@ -1181,6 +1187,8 @@ "Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)", "Reshuffle": "Volver a mezclar", "Resize Face Box": "Cambiar tamaño del recuadro de cara", + "Resize panel window": "Cambiar tamaño de la ventana del panel", + "Resize panels": "Cambiar tamaño de paneles", "Resize to": "Cambiar tamaño a", "Resolution (ppi)": "Resolución (ppp)", "Restore": "Restaurar", diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f3422bc0..53509a50 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1876,5 +1876,13 @@ "Downloading model": "Téléchargement du modèle", "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", - "Looking for changes": "Recherche des modifications" + "Looking for changes": "Recherche des modifications", + "Arrange Panel": "Arrange Panel", + "Close panel": "Close panel", + "Float panel": "Float panel", + "Move to group": "Move to group", + "Panels": "Panels", + "Reset Panel Layout": "Reset Panel Layout", + "Resize panel window": "Resize panel window", + "Resize panels": "Resize panels" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a7a3a32e..2babd30b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -81,6 +81,7 @@ "Aqua Hue": "アクアの色相", "Aqua Luminance": "アクアの輝度", "Aqua Saturation": "アクアの彩度", + "Arrange Panel": "パネルを配置", "ArtCraft Website": "ArtCraftのウェブサイト", "ArtCraft — more creative apps": "ArtCraft — その他のクリエイティブアプリ", "As Shot": "撮影時の設定", @@ -228,6 +229,7 @@ "Close": "閉じる", "Close Panel": "パネルを閉じる", "Close Window": "ウィンドウを閉じる", + "Close panel": "パネルを閉じる", "Cloudy": "曇り", "Collapse All Stacks": "すべてのスタックを閉じる", "Collapse Stack": "スタックを折りたたむ", @@ -476,6 +478,7 @@ "Fixed keys": "固定キー", "Flagged as Pick": "採用フラグ付き", "Flagged as Reject": "不採用フラグ付き", + "Float panel": "パネルをフロート化", "Focal Length": "焦点距離", "Focus (assisted culling)": "ピント(選別補助)", "Food": "料理", @@ -593,6 +596,7 @@ "Move Preset to New Group": "プリセットを新しいグループに移動", "Move Up": "上へ移動", "Move in Stack": "スタック内で移動", + "Move to group": "グループに移動", "Muted": "落ち着いた色", "Muted Film": "ミューテッドフィルム", "My Photos Panel": "「マイフォト」パネル", @@ -621,6 +625,7 @@ "Original + Settings": "元画像+設定", "Original extension without the dot": "元の拡張子(ドットなし)", "Original file name without its extension": "拡張子を除いた元のファイル名", + "Panels": "パネル", "Panorama": "パノラマ", "Panorama Merge Preview": "パノラマ合成のプレビュー", "Panorama with Last Settings": "前回の設定でパノラマ合成", @@ -694,6 +699,7 @@ "Reorder Masks": "マスクを並べ替え", "Reset All": "すべてリセット", "Reset All Keyboard Shortcuts": "キーボードショートカットをすべてリセット", + "Reset Panel Layout": "パネル配置をリセット", "Reshuffle": "シャッフルし直す", "File name": "ファイル名", "Fill Edges": "境界を補完", @@ -1123,6 +1129,8 @@ "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "すべてのポイントカーブとパラメトリックカーブをリセット(チャンネルのダブルクリックで個別にリセット)", "Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)", "Resize Face Box": "顔の枠のサイズを変更", + "Resize panel window": "パネルウィンドウのサイズを変更", + "Resize panels": "パネルのサイズを変更", "Resize to": "サイズ変更", "Resolution (ppi)": "解像度(ppi)", "Restore": "復元", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index c3d9a8d7..dfe47281 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -84,6 +84,7 @@ "Aqua Hue": "Matiz do ciano", "Aqua Luminance": "Luminância do ciano", "Aqua Saturation": "Saturação do ciano", + "Arrange Panel": "Organizar painel", "ArtCraft Website": "Site do ArtCraft", "ArtCraft — more creative apps": "ArtCraft — mais aplicativos criativos", "As Shot": "Como capturado", @@ -237,6 +238,7 @@ "Close Window": "Fechar janela", "Close all": "Fechar tudo", "Close file": "Fechar arquivo", + "Close panel": "Fechar painel", "Cloudy": "Nublado", "Collapse": "Recolher", "Collapse All Stacks": "Recolher todas as pilhas", @@ -529,6 +531,7 @@ "Flip Overlay (⇧O)": "Inverter sobreposição (⇧O)", "Flip V": "Espelhar V", "Flip Vertical": "Espelhar verticalmente", + "Float panel": "Tornar painel flutuante", "Flow": "Fluxo", "Fluorescent": "Fluorescente", "Focal Length": "Distância focal", @@ -852,6 +855,7 @@ "Move them": "Movê-los", "Move to": "Mover para", "Move to Group": "Mover para grupo", + "Move to group": "Mover para grupo", "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Mover: os arquivos são movidos para a pasta abaixo; cada original (e seu sidecar XMP) é removido da origem somente após a cópia ser verificada. Duplicatas e arquivos que falham permanecem onde estão.", "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Move todas as fotos importadas deste disco para Excluídos recentemente; nenhum arquivo é alterado", "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Move as fotos importadas desta pasta para Excluídos recentemente; nenhum arquivo é alterado", @@ -971,6 +975,7 @@ "Paint only areas like the one under the brush": "Pinta apenas áreas como a que está sob o pincel", "Paint over a distraction on the photo to remove it.": "Pinte sobre uma distração na foto para removê-la.", "Paint overlay": "Sobreposição de pintura", + "Panels": "Painéis", "Panorama": "Panorama", "Panorama Merge": "Mesclagem de panorama", "Panorama Merge Preview": "Prévia da mesclagem de panorama", @@ -1185,6 +1190,7 @@ "Reset Control": "Redefinir controle", "Reset Crop": "Redefinir recorte", "Reset Edits": "Redefinir edições", + "Reset Panel Layout": "Redefinir disposição dos painéis", "Reset Parametric Curve": "Redefinir curva paramétrica", "Reset Section": "Redefinir seção", "Reset Tone Curve": "Redefinir curva de tonalidade", @@ -1192,6 +1198,8 @@ "Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)", "Reshuffle": "Embaralhar de novo", "Resize Face Box": "Redimensionar caixa de rosto", + "Resize panel window": "Redimensionar janela do painel", + "Resize panels": "Redimensionar painéis", "Resize to": "Redimensionar para", "Resolution (ppi)": "Resolução (ppi)", "Restore": "Restaurar", diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf997474..fafb65a1 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -60,6 +60,7 @@ "Aqua Hue": "Тон голубого", "Aqua Luminance": "Яркость голубого", "Aqua Saturation": "Насыщенность голубого", + "Arrange Panel": "Расположить панель", "ArtCraft Website": "Сайт ArtCraft", "ArtCraft — more creative apps": "ArtCraft — другие творческие приложения", "As Shot": "Как снято", @@ -194,6 +195,7 @@ "Close Window": "Закрыть окно", "Close all": "Закрыть все", "Close file": "Закрыть файл", + "Close panel": "Закрыть панель", "Cloudy": "Облачно", "Collapse All Stacks": "Свернуть все стопки", "Color": "Цвет", @@ -434,6 +436,7 @@ "Flip Overlay (⇧O)": "Отразить наложение (⇧O)", "Flip V": "Отразить по верт.", "Flip Vertical": "Отразить по вертикали", + "Float panel": "Сделать панель плавающей", "Flow": "Поток", "Fluorescent": "Флуоресцентная лампа", "Focal length": "Фокусное расстояние", @@ -703,6 +706,7 @@ "Move them": "Переместить их", "Move to": "Переместить в", "Move to Group": "Переместить в группу", + "Move to group": "Переместить в группу", "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Перемещение: файлы перемещаются в указанную ниже папку; каждый оригинал (и его XMP-файл) удаляется из источника только после проверки копии. Дубликаты и файлы с ошибками остаются на месте.", "Muted": "Приглушённый", "Muted Film": "Приглушённая плёнка", @@ -804,6 +808,7 @@ "Paint only areas like the one under the brush": "Закрашивать только области, похожие на ту, что под кистью", "Paint over a distraction on the photo to remove it.": "Закрасьте лишнее на фото, чтобы убрать это.", "Paint overlay": "Наложение закраски", + "Panels": "Панели", "Panorama Merge": "Слияние панорамы", "Panorama with Last Settings": "Панорама с последними настройками", "Panorama…": "Панорама…", @@ -977,12 +982,15 @@ "Reset Control": "Сбросить элемент управления", "Reset Crop": "Сбросить кадрирование", "Reset Edits": "Сбросить правки", + "Reset Panel Layout": "Сбросить расположение панелей", "Reset Parametric Curve": "Сбросить параметрическую кривую", "Reset Section": "Сбросить раздел", "Reset Tone Curve": "Сбросить тоновую кривую", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Сбросить все кривые: точечные (все каналы) и параметрическую (двойной щелчок по каналу сбрасывает только его)", "Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)", "Reshuffle": "Перемешать", + "Resize panel window": "Изменить размер окна панели", + "Resize panels": "Изменить размеры панелей", "Resize to": "Изменить размер до", "Resolution (ppi)": "Разрешение (ppi)", "Restore": "Восстановить", diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c73c05b9..7ebad5b1 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2070,5 +2070,13 @@ "Downloading model": "Завантаження моделі", "Finding faces": "Пошук облич", "Synchronizing folder": "Синхронізація папки", - "Looking for changes": "Пошук змін" + "Looking for changes": "Пошук змін", + "Arrange Panel": "Arrange Panel", + "Close panel": "Close panel", + "Float panel": "Float panel", + "Move to group": "Move to group", + "Panels": "Panels", + "Reset Panel Layout": "Reset Panel Layout", + "Resize panel window": "Resize panel window", + "Resize panels": "Resize panels" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 21fa4e4e..8a0e58b8 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -83,6 +83,7 @@ "Aqua Hue": "青色色相", "Aqua Luminance": "青色明度", "Aqua Saturation": "青色饱和度", + "Arrange Panel": "排列面板", "ArtCraft Website": "ArtCraft 网站", "ArtCraft — more creative apps": "ArtCraft — 更多创意应用", "As Shot": "拍摄时设置", @@ -236,6 +237,7 @@ "Close Window": "关闭窗口", "Close all": "全部关闭", "Close file": "关闭文件", + "Close panel": "关闭面板", "Cloudy": "阴天", "Collapse All Stacks": "折叠所有堆叠", "Collapse Stack": "折叠堆叠", @@ -496,6 +498,7 @@ "Fixed keys": "固定按键", "Flagged as Pick": "已标记为留用", "Flagged as Reject": "已标记为排除", + "Float panel": "浮动面板", "Focal Length": "焦距", "Focus (assisted culling)": "对焦(辅助筛选)", "Frame time": "帧耗时", @@ -563,6 +566,7 @@ "Move Preset to New Group": "将预设移动到新分组", "Move Up": "上移", "Move in Stack": "在堆叠中移动", + "Move to group": "移到组中", "Name (A–Z)": "名称(A–Z)", "Name of the folder the original is in": "原始文件所在文件夹的名称", "Named People": "已命名的人物", @@ -580,6 +584,7 @@ "Only": "仅限", "Original extension without the dot": "不含点的原始扩展名", "Original file name without its extension": "不含扩展名的原始文件名", + "Panels": "面板", "Panorama": "全景", "Panorama Merge Preview": "全景合并预览", "Panoramas": "全景", @@ -635,6 +640,7 @@ "Reorder Masks": "重新排序蒙版", "Reset All": "全部重置", "Reset All Keyboard Shortcuts": "重置所有键盘快捷键", + "Reset Panel Layout": "重置面板布局", "Reshuffle": "重新随机排序", "File name": "文件名", "Fill Edges": "填充边缘", @@ -1179,6 +1185,8 @@ "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重置所有曲线:点曲线(所有通道)和参数曲线(双击某个通道可单独重置该通道)", "Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)", "Resize Face Box": "调整人脸框大小", + "Resize panel window": "调整面板窗口大小", + "Resize panels": "调整面板大小", "Resize to": "调整大小为", "Resolution (ppi)": "分辨率 (ppi)", "Restore": "恢复", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d2e0a30c..f1cbec3a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -83,6 +83,7 @@ "Aqua Hue": "青色色相", "Aqua Luminance": "青色明度", "Aqua Saturation": "青色飽和度", + "Arrange Panel": "排列面板", "ArtCraft Website": "ArtCraft 網站", "ArtCraft — more creative apps": "ArtCraft — 更多創作應用程式", "As Shot": "拍攝時設定", @@ -236,6 +237,7 @@ "Close Window": "關閉視窗", "Close all": "全部關閉", "Close file": "關閉檔案", + "Close panel": "關閉面板", "Cloudy": "陰天", "Collapse All Stacks": "收合所有堆疊", "Collapse Stack": "收合堆疊", @@ -496,6 +498,7 @@ "Fixed keys": "固定按鍵", "Flagged as Pick": "已標記為精選", "Flagged as Reject": "已標記為排除", + "Float panel": "浮動面板", "Focal Length": "焦距", "Focus (assisted culling)": "焦點(輔助挑選)", "Frame time": "影格時間", @@ -563,6 +566,7 @@ "Move Preset to New Group": "將預設集移至新群組", "Move Up": "上移", "Move in Stack": "在堆疊中移動", + "Move to group": "移至群組", "Name (A–Z)": "名稱(A–Z)", "Name of the folder the original is in": "原始檔所在資料夾的名稱", "Named People": "已命名的人物", @@ -580,6 +584,7 @@ "Only": "僅限", "Original extension without the dot": "不含點的原始副檔名", "Original file name without its extension": "不含副檔名的原始檔名", + "Panels": "面板", "Panorama": "全景", "Panorama Merge Preview": "全景合併預覽", "Panoramas": "全景", @@ -635,6 +640,7 @@ "Reorder Masks": "重新排序遮色片", "Reset All": "全部重設", "Reset All Keyboard Shortcuts": "重設所有鍵盤快速鍵", + "Reset Panel Layout": "重設面板配置", "Reshuffle": "重新隨機排序", "File name": "檔案名稱", "Fill Edges": "填補邊緣", @@ -1179,6 +1185,8 @@ "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "重設所有點曲線通道與參數曲線(按兩下通道可只重設該通道)", "Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)", "Resize Face Box": "調整臉部框大小", + "Resize panel window": "調整面板視窗大小", + "Resize panels": "調整面板大小", "Resize to": "調整尺寸為", "Resolution (ppi)": "解析度(ppi)", "Restore": "還原", diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a8643db1..a48e74cd 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -320,9 +320,13 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest lightcraft_develop::presets::deep_merge(&mut v, p); match serde_json::from_value::(v) { Ok(mut u) => { + if let Err(error) = crate::docking::set_widths(&mut u, p) { + return err(error); + } u.toast = app.ui.toast.clone(); u.dialog = app.ui.dialog.clone(); u.status = app.ui.status.clone(); + crate::docking::cancel_replaced_gesture(app, u.docking.as_ref()); app.ui = u; crate::i18n::set_language(app.ui.language); ctx.request_repaint(); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs new file mode 100644 index 00000000..63958942 --- /dev/null +++ b/crates/ui-egui/src/docking.rs @@ -0,0 +1,653 @@ +//! 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, + Library, + Tools, + Presets, +} + +impl Panel { + pub const ALL: &[Self] = &[Self::Canvas, Self::Library, Self::Tools, Self::Presets]; + pub fn id(self) -> &'static str { + match self { + Self::Canvas => "canvas", + Self::Library => "library", + Self::Tools => "tools", + Self::Presets => "presets", + } + } + fn label(self) -> &'static str { + match self { + Self::Canvas => "Photos", + Self::Library => "Library", + Self::Tools => "Tools", + Self::Presets => "Presets", + } + } + fn default_side(self) -> Zone { + match self { + Self::Canvas => Zone::Right, + Self::Library => Zone::Left, + Self::Tools => Zone::Right, + Self::Presets => 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(crate::state::LEFT_WIDTH.default), + tabs(Panel::Library), + split( + SplitAxis::Horizontal, + SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.default), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), + tabs(Panel::Tools), + ), + )), + 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(()) + } + + 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 mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); + next.hidden.retain(|(id, _)| *id != panel); + if let Some(saved) = &mut location + && saved.floating.is_some() + && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) + { + // The other members of a floating group may have been closed too. + saved.anchor = None; + saved.placement = Placement::Tab { before: None }; + } + 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(()) + } + + /// Membership is passive state; an explicit command also exposes the panel's body. + pub fn reveal(&mut self, panel: Panel) -> Result<(), String> { + self.set_visible(panel, true)?; + self.apply(Action::Activate { panel }) + } + + pub fn exposed(&self, panel: Panel) -> bool { + if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) { + return true; + } + let mut nodes = self.layout.root.as_ref().map(|node| vec![node]).unwrap_or_default(); + while let Some(node) = nodes.pop() { + match node { + Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true, + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true, + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + _ => {} + } + } + false + } + + 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), + "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 from_ui(app: &crate::LightcraftApp) -> Workspace { + let mut workspace = Workspace::default(); + if let Some(Node::Split { size, second, .. }) = workspace.layout.root.as_mut() { + *size = SplitSize::FixedFirst(crate::state::LEFT_WIDTH.clamp(app.ui.left_width)); + if let Node::Split { size, .. } = second.as_mut() { + *size = SplitSize::FixedSecond(crate::state::RIGHT_WIDTH.clamp(app.ui.right_width)); + } + } + workspace +} + +fn sync(workspace: &mut Workspace, app: &crate::LightcraftApp) -> Result<(), String> { + workspace.set_visible(Panel::Library, app.ui.left_panel)?; + workspace.set_visible(Panel::Tools, app.ui.right != crate::state::RightPanel::None)?; + workspace.set_visible(Panel::Presets, app.ui.presets) +} + +fn publish_visibility(app: &mut crate::LightcraftApp, workspace: &Workspace) { + app.ui.left_panel = workspace.layout.contains(&Panel::Library); + app.ui.presets = workspace.layout.contains(&Panel::Presets); + if !workspace.layout.contains(&Panel::Tools) { + app.ui.right = crate::state::RightPanel::None; + } else if app.ui.right == crate::state::RightPanel::None { + app.ui.right = crate::state::RightPanel::Edit; + } + for (panel, width, limits) in + [(Panel::Library, &mut app.ui.left_width, crate::state::LEFT_WIDTH), (Panel::Tools, &mut app.ui.right_width, crate::state::RIGHT_WIDTH)] + { + let Ok(location) = workspace.layout.location(&panel) else { + continue; + }; + let value = match (location.floating, location.placement, location.size) { + (Some([_, _, width, _]), _, _) + | (None, Placement::Split(Zone::Left), Some(SplitSize::FixedFirst(width))) + | (None, Placement::Split(Zone::Right), Some(SplitSize::FixedSecond(width))) => Some(width), + // A vertical split stores height, and the opposite fixed side sizes a sibling. + _ => None, + }; + if let Some(value) = value { + *width = limits.clamp(value); + } + } +} + +pub(crate) fn exposed(app: &crate::LightcraftApp, panel: Panel) -> bool { + app.ui.docking.as_ref().is_none_or(|workspace| workspace.exposed(panel)) +} + +pub(crate) fn reveal_command(app: &mut crate::LightcraftApp, id: &str) -> Result<(), String> { + let panel = if id == "panel.presets" && app.ui.presets { + Some(Panel::Presets) + } else if (id.starts_with("panel.") && id != "panel.close" && id != "panel.presets" + || id.starts_with("section.") + || id.starts_with("tool.") && id != "tool.none") + && app.ui.right != crate::state::RightPanel::None + { + Some(Panel::Tools) + } else { + None + }; + if let Some(panel) = panel { + let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); + sync(&mut workspace, app)?; + workspace.reveal(panel)?; + cancel_replaced_gesture(app, Some(&workspace)); + app.ui.docking = Some(workspace); + } + Ok(()) +} + +/// Check the target's actual egui layer even while a different widget owns the drag. +pub(crate) fn drop_target(ui: &egui::Ui, rect: egui::Rect, pointer: Option) -> bool { + pointer.is_some_and(|pos| rect.intersect(ui.clip_rect()).contains(pos) && ui.ctx().layer_id_at(pos) == Some(ui.layer_id())) +} + +/// Explicit host commands replace the snapshot before rendering. Cancel only their old +/// gestures; actions returned by the renderer must keep continuous resizing alive. +pub(crate) fn cancel_replaced_gesture(app: &crate::LightcraftApp, workspace: Option<&Workspace>) { + let replaced = match (app.ui.docking.as_ref(), workspace) { + (Some(old), Some(next)) => old.layout != next.layout || old.hidden != next.hidden, + (None, None) => false, + _ => true, + }; + if replaced && let Some(ctx) = &app.tasks.repaint { + craft_ui::docking::cancel_drag::(ctx, egui::Id::new("lightcraft-docking")); + } +} + +pub fn command(app: &mut crate::LightcraftApp, reset: bool, params: &Value) -> Result { + command_impl(app, reset, params, false) +} + +fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, renderer_action: bool) -> Result { + let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); + if reset { + workspace = Workspace::default(); + // The usual LightCraft starting workspace shows its current tool beside the photo. + workspace.set_visible(Panel::Library, false)?; + workspace.set_visible(Panel::Presets, false)?; + } else { + sync(&mut workspace, app)?; + workspace.command(params)?; + } + if !renderer_action { + cancel_replaced_gesture(app, Some(&workspace)); + } + publish_visibility(app, &workspace); + app.ui.docking = Some(workspace); + Ok(json!({"docking": app.ui.docking})) +} + +pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { + let t = crate::theme::Tokens::get(ui.ctx()); + let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); + if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) { + app.ui.toast = Some((format!("Panel layout could not be restored: {error}"), ui.input(|input| input.time) + 8.0, None)); + workspace = from_ui(app); + let _ = sync(&mut workspace, app); + } + // Legacy visibility commands may replace the layout before this render starts. + cancel_replaced_gesture(app, Some(&workspace)); + // Tool/Canvas callbacks dispatch app commands and must see the real workspace. + app.ui.docking = Some(workspace.clone()); + let mut style = DockStyle::from_ui(ui); + style.min_pane = 96.0; + style.float_label = crate::i18n::tr("Float panel").into(); + style.close_label = crate::i18n::tr("Close panel").into(); + style.move_label = crate::i18n::tr("Move to group").into(); + style.panels_label = crate::i18n::tr("Panels").into(); + style.resize_label = crate::i18n::tr("Resize panels").into(); + style.resize_window_label = crate::i18n::tr("Resize panel window").into(); + style.background = t.chrome; + style.tab_background = t.chrome; + style.active_background = t.canvas; + style.text = t.text; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.divider); + style.accent = t.accent; + style.font = t.font(12.0); + // Tool and preset panels publish hover previews before the photo consumes them. + let mut canvas = None; + let output = DockArea::new(egui::Id::new("lightcraft-docking")).show_with_limits( + ui, + &workspace.layout, + &style, + |panel| crate::i18n::tr(panel.label()).into(), + permissions, + panel_limits, + |ui, panel| match panel { + Panel::Canvas => { + canvas = Some(ui.new_child(egui::UiBuilder::new().id_salt("photo-view").max_rect(ui.max_rect()))); + } + Panel::Library => { + crate::widgets::register(ui.ctx(), "panel:left_panel", ui.max_rect()); + crate::panels::left::show(app, ui); + } + Panel::Tools => { + crate::widgets::register(ui.ctx(), "panel:right_panel", ui.max_rect()); + crate::panels::right::show(app, ui); + } + Panel::Presets => crate::panels::presets::show(app, ui), + }, + ); + if let Some(mut canvas) = canvas { + let ui = &mut canvas; + crate::panels::bottombar::show(app, ui); + let bg = if matches!( + app.ui.view, + crate::state::ViewMode::Detail | crate::state::ViewMode::Compare | crate::state::ViewMode::Survey | crate::state::ViewMode::Reference + ) { + t.canvas + } else { + t.grid_bg + }; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match app.ui.view { + crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid => crate::panels::grid::show(app, ui), + crate::state::ViewMode::Detail => crate::panels::detail::show(app, ui), + crate::state::ViewMode::Compare => crate::panels::compare::show_compare(app, ui), + crate::state::ViewMode::Survey => crate::panels::compare::show_survey(app, ui), + crate::state::ViewMode::Reference => crate::panels::compare::show_reference(app, ui), + crate::state::ViewMode::People => crate::panels::people::show(app, ui), + }); + } + + let rendered = workspace; + let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); + 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("lightcraft-docking")); + } + publish_visibility(app, &workspace); + app.ui.docking = Some(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_impl(app, false, &json!({"action": action}), true)) + { + app.ui.toast = Some((format!("Panel move was not applied: {error}"), ui.input(|input| input.time) + 8.0, None)); + } + } +} + +/// Keep the established ui.set width controls connected to the panel's current parent split. +pub(crate) fn set_widths(state: &mut crate::UiState, params: &Value) -> Result<(), String> { + state.left_width = crate::state::LEFT_WIDTH.clamp(state.left_width); + state.right_width = crate::state::RIGHT_WIDTH.clamp(state.right_width); + let Some(workspace) = state.docking.as_mut() else { + return Ok(()); + }; + workspace.validate()?; + for (key, panel, width) in [("leftWidth", Panel::Library, state.left_width), ("rightWidth", Panel::Tools, state.right_width)] { + if params.get(key).is_none() { + continue; + } + if let Some((_, location)) = workspace.hidden.iter_mut().find(|(id, _)| *id == panel) { + if let Some(rect) = &mut location.floating { + rect[2] = width; + } + match location.placement { + Placement::Split(Zone::Left) => location.size = Some(SplitSize::FixedFirst(width)), + Placement::Split(Zone::Right) => location.size = Some(SplitSize::FixedSecond(width)), + _ => {} // Width controls do not rewrite a saved vertical split's height. + } + continue; + } + if let Some(group) = workspace.layout.floating.iter().find(|group| group.panels.contains(&panel)) { + let mut rect = group.rect; + rect[2] = width; + workspace.apply(Action::MoveFloating { panel, rect })?; + continue; + } + let mut nodes = workspace.layout.root.as_ref().map(|node| vec![(node, Vec::new())]).unwrap_or_default(); + let mut change = None; + while let Some((node, path)) = nodes.pop() { + if let Node::Split { axis, first, second, .. } = node { + let contains = |node: &Node| match node { + Node::Tabs { panels, .. } => panels.contains(&panel), + Node::Stack { entries } => entries.iter().any(|entry| entry.panel == panel), + Node::Split { .. } => false, + }; + if *axis == SplitAxis::Horizontal { + if contains(first) { + change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedFirst(width) }); + } + if contains(second) { + change = Some(Action::ResizeSplit { path: path.clone(), size: SplitSize::FixedSecond(width) }); + } + } + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + nodes.push((second, second_path)); + nodes.push((first, first_path)); + } + } + if let Some(action) = change { + workspace.apply(action)?; + } + } + Ok(()) +} + +fn panel_limits(panel: &Panel) -> PanelLimits { + let (min, max_width) = match panel { + Panel::Canvas => (egui::vec2(crate::state::MIN_PHOTO_WIDTH, 160.0), 1_000_000.0), + Panel::Library => (egui::vec2(crate::state::LEFT_WIDTH.min, 120.0), crate::state::LEFT_WIDTH.max), + Panel::Tools => (egui::vec2(crate::state::RIGHT_WIDTH.min, 120.0), crate::state::RIGHT_WIDTH.max), + Panel::Presets => (egui::vec2(250.0, 120.0), 520.0), + }; + PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app_with_widths() -> crate::LightcraftApp { + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default()); + app.ui.left_width = 333.0; + app.ui.right_width = 410.0; + app + } + + fn tools_split(axis: SplitAxis, size: SplitSize, first: bool) -> Workspace { + let rest = split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(333.0), + tabs(Panel::Library), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(300.0), tabs(Panel::Canvas), tabs(Panel::Presets)), + ); + let root = if first { split(axis, size, tabs(Panel::Tools), rest) } else { split(axis, size, rest, tabs(Panel::Tools)) }; + Workspace { layout: Layout { root: Some(root), floating: Vec::new() }, hidden: Vec::new() } + } + + #[test] + fn explicit_reveal_opens_collapsed_tools_and_survives_reload() { + let mut app = app_with_widths(); + app.ui.right = crate::state::RightPanel::Crop; + let entries = [Panel::Tools, Panel::Library, Panel::Presets] + .into_iter() + .map(|panel| craft_ui::docking::StackEntry { panel, open: panel != Panel::Tools, height: Some(180.0) }) + .collect(); + app.ui.docking = Some(Workspace { + layout: Layout { + root: Some(split(SplitAxis::Horizontal, SplitSize::FixedSecond(350.0), tabs(Panel::Canvas), Node::Stack { entries })), + floating: Vec::new(), + }, + hidden: Vec::new(), + }); + let undo = app.session.undo.len(); + app.run("panel.crop", json!({})).unwrap(); + assert_eq!(app.ui.right, crate::state::RightPanel::Crop); + let workspace = app.ui.docking.as_ref().unwrap(); + assert!(workspace.exposed(Panel::Tools)); + assert_eq!(app.session.undo.len(), undo); + let saved = serde_json::to_string(workspace).unwrap(); + let reloaded: Workspace = serde_json::from_str(&saved).unwrap(); + reloaded.validate().unwrap(); + assert!(reloaded.exposed(Panel::Tools)); + } + + #[test] + fn legacy_width_publication_ignores_heights_and_sibling_widths() { + for (axis, size, first) in [ + (SplitAxis::Vertical, SplitSize::FixedFirst(180.0), true), + (SplitAxis::Vertical, SplitSize::FixedSecond(180.0), false), + (SplitAxis::Horizontal, SplitSize::FixedSecond(180.0), true), + (SplitAxis::Horizontal, SplitSize::FixedFirst(180.0), false), + ] { + let workspace = tools_split(axis, size, first); + workspace.validate().unwrap(); + let mut app = app_with_widths(); + publish_visibility(&mut app, &workspace); + assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 410.0), "{axis:?} {size:?}, first={first}"); + } + } + + #[test] + fn legacy_width_publication_reads_own_horizontal_and_floating_widths() { + for (size, first) in [(SplitSize::FixedFirst(310.0), true), (SplitSize::FixedSecond(310.0), false)] { + let workspace = tools_split(SplitAxis::Horizontal, size, first); + let mut app = app_with_widths(); + publish_visibility(&mut app, &workspace); + assert_eq!((app.ui.left_width, app.ui.right_width), (333.0, 310.0)); + } + let mut workspace = Workspace::default(); + workspace.apply(Action::Float { panel: Panel::Tools, rect: [12.0, 34.0, 390.0, 450.0] }).unwrap(); + let mut app = app_with_widths(); + publish_visibility(&mut app, &workspace); + assert_eq!(app.ui.right_width, 390.0); + } + + #[test] + fn legacy_width_update_preserves_hidden_top_panel_height() { + let mut workspace = Workspace::default(); + workspace.apply(Action::Move { panel: Panel::Tools, anchor: Panel::Canvas, placement: Placement::Split(Zone::Top) }).unwrap(); + workspace.set_visible(Panel::Tools, false).unwrap(); + let height = workspace.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size; + let mut state = crate::UiState { docking: Some(workspace), right_width: 410.0, ..Default::default() }; + set_widths(&mut state, &json!({"rightWidth":410.0})).unwrap(); + assert_eq!(state.docking.as_ref().unwrap().hidden.iter().find(|(panel, _)| *panel == Panel::Tools).unwrap().1.size, height); + } +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d0104f7d..c423bc35 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2493,9 +2493,19 @@ mod tests { h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), t); h.request("ui.set", json!({"view": "detail", "right": "info"}), t); let before = h.app.session.catalog.photo(id).unwrap().captured.clone().unwrap(); + // The dock header leaves less vertical space; bring the metadata action into view. + let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; + let clipped_action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; + assert!(!panel.contains_rect(clipped_action), "the capture-time action starts below the visible panel: {clipped_action:?}"); + h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), t); + h.request("ui.scroll", json!({"dy": -160.0}), t); + h.settle(SETTLE); + let action = h.app.widgets.iter().find(|(id, _)| id == "icon:editCaptureTime").unwrap().1; + assert!(panel.contains_rect(action), "capture-time action must be visible before clicking: {action:?} in {panel:?}"); let r = h.request("ui.clickWidget", json!({"id": "icon:editCaptureTime"}), t); assert_eq!(r["ok"], true, "{r}"); - h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t); + let r = h.request("ui.clickWidget", json!({"id": "button:captureMode-2"}), t); + assert_eq!(r["ok"], true, "{r}"); if let Some(crate::state::Dialog::CaptureTime { zone, .. }) = &mut h.app.ui.dialog { *zone = -3.0; } else { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0..f853fe16 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod album_picker; pub mod control; pub mod credits; pub mod date_picker; +pub mod docking; pub mod export_task; pub mod headless; pub mod i18n; @@ -415,6 +416,9 @@ impl LightcraftApp { return result; } if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + if r.is_ok() { + docking::reveal_command(self, id)?; + } return r; } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); @@ -1037,31 +1041,7 @@ impl LightcraftApp { panels::topbar::show(self, ui); panels::library_problem::banner(self, ui); panels::strip::show(self, ui); - if self.ui.right != state::RightPanel::None { - panels::right::show(self, ui); - } - if self.ui.presets { - panels::presets::show(self, ui); - } - if self.ui.left_panel { - panels::left::show(self, ui); - } - panels::bottombar::show(self, ui); - let t = theme::Tokens::get(&ctx); - let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey | state::ViewMode::Reference) - { - t.canvas - } else { - t.grid_bg - }; - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view { - state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui), - state::ViewMode::Detail => panels::detail::show(self, ui), - state::ViewMode::Compare => panels::compare::show_compare(self, ui), - state::ViewMode::Survey => panels::compare::show_survey(self, ui), - state::ViewMode::Reference => panels::compare::show_reference(self, ui), - state::ViewMode::People => panels::people::show(self, ui), - }); + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| docking::show(self, ui)); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); @@ -1616,3 +1596,6 @@ mod cache_tests { #[cfg(test)] mod tests_model_setup; + +#[cfg(test)] +mod tests_docking_acceptance; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index aff0326a..81a46eb9 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -51,6 +51,8 @@ pub fn language_from_command(id: &str) -> Option { } pub const UI_COMMANDS: &[UiCommand] = &[ + ("ui.dock", "Arrange Panel", None, ""), + ("ui.dock.reset", "Reset Panel Layout", None, "Window"), ("modelSetup.cancel", "Cancel Pending Photo Action", None, ""), ("view.photoGrid", "Photo Grid", None, "View"), ("view.squareGrid", "Square Grid", None, "View"), @@ -204,7 +206,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ]; fn panel(app: &mut LightcraftApp, ctx: &egui::Context, p: RightPanel, name: &str) { - if app.ui.right == p { + if app.ui.right == p && crate::docking::exposed(app, crate::docking::Panel::Tools) { app.ui.right = RightPanel::None; app.toast(ctx, crate::i18n::tr_format!("{name} Off", name = crate::i18n::tr(name))); } else { @@ -266,6 +268,9 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { + if matches!(id, "ui.dock" | "ui.dock.reset") { + return Some(crate::docking::command(app, id == "ui.dock.reset", p)); + } if matches!(id, "library.inspectLightroom" | "library.importLightroom") { // the app's own context: a fresh one's repaints reach no window and its clock starts at zero let ctx = app.tasks.repaint.clone().unwrap_or_default(); @@ -751,7 +756,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { // toggles between the profile browser and the Edit panel it belongs to - app.ui.right = if app.ui.right == RightPanel::Profiles { RightPanel::Edit } else { RightPanel::Profiles }; + app.ui.right = if app.ui.right == RightPanel::Profiles && crate::docking::exposed(app, crate::docking::Panel::Tools) { + RightPanel::Edit + } else { + RightPanel::Profiles + }; if !matches!(app.ui.view, ViewMode::Detail) { app.ui.view = ViewMode::Detail; } @@ -793,7 +802,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.presets = !app.ui.presets; + app.ui.presets = !app.ui.presets || !crate::docking::exposed(app, crate::docking::Panel::Presets); if app.ui.presets && app.ui.view != ViewMode::Detail { app.ui.view = ViewMode::Detail; } diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index de0409ec..89de0263 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -10,6 +10,15 @@ use crate::state::{BeforeAfter, ViewMode, Zoom}; use crate::theme::Tokens; use crate::widgets::{icon_button, register, stars}; +const VIEW_MODES: &[(&str, Icon, ViewMode, &str)] = &[ + ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), + ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"), + ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), + ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), + ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), + ("people", Icon::Subject, ViewMode::People, "People"), +]; + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("bottom_bar") @@ -17,32 +26,52 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - let left_end = ui + let compact_zoom = full.width() < 360.0; + let right_start = right_side(app, ui, full, compact_zoom); + let left_rect = Rect::from_min_max(full.min, pos2((right_start - 12.0).max(full.left()), full.bottom())); + let mut left_ui = ui.new_child(egui::UiBuilder::new().max_rect(left_rect)); + let left_end = left_ui .horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - for (id, icon, mode, tip) in [ - ("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"), - ("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (G toggles)"), - ("detail", Icon::Single, ViewMode::Detail, "Detail (D)"), - ("compare", Icon::Compare, ViewMode::Compare, "Compare (Shift+C)"), - ("survey", Icon::Survey, ViewMode::Survey, "Survey (N)"), - ("people", Icon::Subject, ViewMode::People, "People"), - ] { - if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { - let _ = app.run(&format!("view.{id}"), json!({})); + if left_rect.width() >= 270.0 { + for &(id, icon, mode, tip) in VIEW_MODES { + if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() { + let _ = app.run(&format!("view.{id}"), json!({})); + } } + ui.add_space(10.0); + let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover()); + ui.painter().rect_filled(sep, 0.0, t.button_border); + ui.add_space(10.0); + let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort"); + egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui)); + } else { + let current_icon = VIEW_MODES.iter().find(|(_, _, mode, _)| *mode == app.ui.view).map_or(Icon::More, |(_, icon, _, _)| *icon); + let more = icon_button(ui, "viewOptions", current_icon, vec2(40.0, 32.0), true, true, "View"); + paint( + ui.painter(), + Rect::from_center_size(more.rect.right_bottom() - vec2(5.0, 5.0), vec2(7.0, 7.0)), + Icon::ChevronDown, + t.text, + ); + egui::Popup::menu(&more).show(|ui| { + for &(id, _, mode, label) in VIEW_MODES { + if ui.selectable_label(app.ui.view == mode, crate::i18n::tr(label)).clicked() { + let _ = app.run(&format!("view.{id}"), json!({})); + ui.close(); + } + } + ui.separator(); + ui.menu_button(crate::i18n::tr("Sort"), |ui| sort_menu(app, ui)); + if compact_zoom && matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { + ui.menu_button(crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| click_zoom_menu(app, ui)); + } + }); } - ui.add_space(10.0); - let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover()); - ui.painter().rect_filled(sep, 0.0, t.button_border); - ui.add_space(10.0); - let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort"); - egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui)); }) .response .rect .right(); - let right_start = right_side(app, ui, full); centre(app, ui, full, left_end + 12.0, right_start - 12.0); }); } @@ -123,11 +152,11 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } /// The right-hand group (zoom, view toggles or thumbnail size); returns its left edge. -fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { +fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zoom: bool) -> f32 { let t = Tokens::get(ui.ctx()); let mut child = ui.new_child( egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom())) + .max_rect(Rect::from_min_max(pos2((full.right() - 360.0).max(full.left()), full.top()), full.right_bottom())) .layout(egui::Layout::right_to_left(egui::Align::Center)), ); child.spacing_mut().item_spacing.x = 6.0; @@ -191,21 +220,17 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { } } }); - child.add_space(6.0); - let cz = crate::widgets::dropdown( - &mut child, - "clickZoom", - &crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), - t.font(13.0), - t.text_label, - ); - egui::Popup::menu(&cz).show(|ui| { - for pct in crate::state::CLICK_ZOOMS { - if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() { - let _ = app.run("view.clickZoom", json!({"ratio": pct / 100})); - } - } - }); + if !compact_zoom { + child.add_space(6.0); + let cz = crate::widgets::dropdown( + &mut child, + "clickZoom", + &crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), + t.font(13.0), + t.text_label, + ); + egui::Popup::menu(&cz).show(|ui| click_zoom_menu(app, ui)); + } } else { // thumbnail size slider let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); @@ -225,6 +250,14 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect) -> f32 { if child.min_rect().width() > 0.0 { child.min_rect().left() } else { full.right() } } +fn click_zoom_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { + for pct in crate::state::CLICK_ZOOMS { + if ui.selectable_label(app.ui.click_zoom == pct, format!("{}:1", pct / 100)).clicked() { + let _ = app.run("view.clickZoom", json!({"ratio": pct / 100})); + } + } +} + fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { use lightcraft_catalog::GroupBy; use lightcraft_catalog::SortKey::*; @@ -270,3 +303,156 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } } + +#[cfg(test)] +mod visual_regressions { + use super::*; + + fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { + let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale); + input + } + + #[test] + fn capture_bottom_bar_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for width in [280.0, 400.0, 600.0, 1000.0] { + for view in [ + ViewMode::PhotoGrid, + ViewMode::SquareGrid, + ViewMode::Detail, + ViewMode::Compare, + ViewMode::Survey, + ViewMode::Reference, + ViewMode::People, + ] { + for scale in [1.0, 1.5, 2.0] { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default()); + app.ui.view = view; + let mut ready = false; + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(width, 100.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut LightcraftApp| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx()); + ready = true; + return; + } + crate::widgets::take_registry(ui.ctx()); + show(app, ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("lightcraft-bottom-{view:?}-{width}-{scale}x"); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + let panel = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new("bottom_bar")).unwrap().outer_rect; + let controls: serde_json::Map = crate::widgets::take_registry(&harness.ctx) + .into_iter() + .map(|(id, r)| (id, json!([r.left(), r.top(), r.width(), r.height()]))) + .collect(); + std::fs::write( + directory.join(format!("{stem}.json")), + serde_json::to_vec_pretty(&json!({ + "screen": [width, 100.0], "scale": scale, + "regions": {"bottom-bar": [panel.left(), panel.top(), panel.width(), panel.height()]}, + "controls": controls + })) + .unwrap(), + ) + .unwrap(); + } + } + } + } + + #[test] + fn bottom_bar_controls_do_not_overlap_in_narrow_canvas() { + for width in [280.0, 400.0, 600.0, 1000.0] { + for view in [ + ViewMode::PhotoGrid, + ViewMode::SquareGrid, + ViewMode::Detail, + ViewMode::Compare, + ViewMode::Survey, + ViewMode::Reference, + ViewMode::People, + ] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default()); + app.ui.view = view; + let mut output = ctx.run_ui(scaled_input(vec2(width, 100.0), scale), |ui| show(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(width, 100.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let controls = crate::widgets::take_registry(&ctx); + assert!(!controls.is_empty()); + for (index, (id, rect)) in controls.iter().enumerate() { + assert!(rect.left() >= 0.0 && rect.right() <= width, "{view:?} at {width}: {id} outside viewport: {rect:?}"); + for (other_id, other) in controls.iter().skip(index + 1) { + assert!(!rect.intersect(*other).is_positive(), "{view:?} at {width}: {id} overlaps {other_id}: {rect:?} / {other:?}"); + } + } + } + } + } + } + #[test] + fn compact_view_menu_opens_and_changes_view_by_keyboard() { + use egui_kittest::kittest::Queryable; + + for scale in [1.0, 1.5, 2.0] { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Default::default()); + let mut ready = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(280.0, 640.0)).with_pixels_per_point(scale).build_ui_state( + move |ui, app: &mut LightcraftApp| { + if !ready { + crate::theme::install_fonts(ui.ctx()); + crate::theme::apply(ui.ctx()); + ready = true; + return; + } + crate::shortcuts::handle(app, ui.ctx()); + crate::widgets::take_registry(ui.ctx()); + show(app, ui); + }, + app, + ); + harness.run_steps(4); + assert_eq!(harness.ctx.content_rect().size(), vec2(280.0, 640.0)); + assert_eq!(harness.ctx.pixels_per_point(), scale); + let mut focused = false; + for _ in 0..12 { + harness.key_press(egui::Key::Tab); + harness.run_steps(3); + focused = harness.get_by_label("View").is_focused(); + if focused { + break; + } + } + assert!(focused, "Tab reaches the compact View control at {scale}x"); + harness.key_press(egui::Key::Space); + harness.run_steps(3); + let mut people_focused = false; + for _ in 0..20 { + harness.key_press(egui::Key::Tab); + harness.run_steps(3); + people_focused = harness.get_by_label("People").is_focused(); + if people_focused { + break; + } + } + assert!(people_focused, "Tab reaches People at {scale}x"); + harness.key_press(egui::Key::Space); + harness.run_steps(3); + assert_eq!(harness.state().ui.view, ViewMode::People, "keyboard activation dispatches the view command"); + } + } +} diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index f201359d..bf0daeb4 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -107,7 +107,7 @@ pub(crate) fn follow(app: &mut LightcraftApp, from: &str, to: &str) { /// The pointer is on the part of `rect` that is shown: inside it and inside the panel's visible /// (scrolled) area, so a drop never lands on a row or title hidden under the top bar. fn shown_under(ui: &egui::Ui, rect: Rect, pointer: Option) -> bool { - pointer.is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) + crate::docking::drop_target(ui, rect, pointer) } /// A keyword being dragged, each frame (after the panels, which take the drop): its name follows diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index b435f6ed..4f1bae44 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -180,8 +180,7 @@ fn sidebar_section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); - let width = app.ui.left_width; - let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |ui| { + frame.show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text); @@ -300,9 +299,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); }); - if let Some(w) = resized { - app.ui.left_width = w; - } } /// How many library photos have no file (Local browse records are not checked; see @@ -775,7 +771,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare /// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of /// sight is not a drop target). fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { - ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) + crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos())) } /// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. @@ -1010,7 +1006,7 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { /// release there adds them. fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { let Some(ids) = app.ui.dragging_photos.clone() else { return }; - let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p)); + let over = pointer_over(ui, resp.rect); if !over { return; } diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index f60ddc5d..0968ab67 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -19,224 +19,206 @@ const THUMB_EDGE: usize = 128; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::right("presets_panel") - .exact_size(t.panel_w) - .resizable(false) - .frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider))) - .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); - ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr("Presets"), t.semibold(15.0), t.text); - let mut hdr = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom())) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() - { - app.ui.dialog = Some(crate::state::Dialog::create_preset()); + egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); + ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr("Presets"), t.semibold(15.0), t.text); + let mut hdr = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom())) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() { + app.ui.dialog = Some(crate::state::Dialog::create_preset()); + } + let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options"); + egui::Popup::menu(&more).show(|ui| { + let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails")); + register(ui.ctx(), "presetMenu:thumbnails", r.rect); + ui.separator(); + if ui.button(crate::i18n::tr("Import Presets…")).clicked() { + let _ = app.run("file.importPresets", json!({})); } - let more = icon_button(&mut hdr, "presetMore", Icon::More, vec2(28.0, 28.0), false, true, "More preset options"); - egui::Popup::menu(&more).show(|ui| { - let r = ui.checkbox(&mut app.ui.preset_thumbs, crate::i18n::tr("Show Thumbnails")); - register(ui.ctx(), "presetMenu:thumbnails", r.rect); - ui.separator(); - if ui.button(crate::i18n::tr("Import Presets…")).clicked() { - let _ = app.run("file.importPresets", json!({})); - } - let any_user = app.session.presets.iter().any(|p| !p.builtin); - if ui.add_enabled(any_user, egui::Button::new(crate::i18n::tr("Export User Presets…"))).clicked() { - let _ = app.run("file.exportPresets", json!({})); - } - }); - divider(ui); - // amount slider (applies to the last applied preset) - let amt_id = egui::Id::new("preset-amount"); - let last: Option<(String, f64)> = ui.data(|d| d.get_temp(amt_id)); - if let Some((pid, amount)) = last.clone() { - let spec = ControlSpec { - id: "presetAmount", - label: "Amount", - section: Section::Profile, - min: 0.0, - max: 200.0, - default: 100.0, - step: 1.0, - decimals: 0, - track: Track::Plain, - }; - let out = slider(ui, &spec, amount, true, None); - if let Some(v) = out.value { - // re-apply from the pre-preset state: undo last preset step then apply with the new amount - let _ = app.run("edit.undo", json!({})); - let _ = app.run("preset.apply", json!({"id": pid, "amount": v})); - ui.data_mut(|d| d.insert_temp(amt_id, (pid, v))); - } - divider(ui); - } - let active = app.session.active(); - let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); - let thumbs = app.ui.preset_thumbs && active.is_some(); - let row_h = if thumbs { 54.0 } else { 26.0 }; - let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - let mut groups: BTreeMap> = BTreeMap::new(); - for p in &app.session.presets { - groups.entry(p.group.clone()).or_default().push(p.clone()); - } - for (g, items) in groups { - // A group holding stock presets is a stock group (shown in the UI language), even - // when user presets share its name; a group of user presets keeps its name. - let builtin = items.iter().any(|p| p.builtin); - let open_id = egui::Id::new(("preset-group", &g)); - let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); - register(ui.ctx(), format!("presetGroup:{g}"), r); - paint( - ui.painter(), - Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), - if open { Icon::ChevronDown } else { Icon::ChevronRight }, - t.text_label, - ); - ui.painter().text( - pos2(r.left() + 36.0, r.center().y), - Align2::LEFT_CENTER, - crate::i18n::builtin_label(&g, builtin), - t.semibold(13.0), - t.text_label, - ); - if resp.clicked() { - ui.data_mut(|d| d.insert_temp(open_id, !open)); - } - resp.context_menu(|ui| { - if ui.button(crate::i18n::tr("Export Group…")).clicked() { - let _ = app.run("file.exportPresets", json!({"group": g})); - } - }); - if !open { - continue; - } - for pr in items { - let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); - register(ui.ctx(), format!("preset:{pid}"), r); - let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); - if is_last { - ui.painter().rect_filled(r, 0.0, t.tool_active); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7)); - } - let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None }; - let mut text_x = r.left() + 40.0; - if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) { - let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0)); - ui.painter().rect_filled(tr, 2.0, t.canvas); - if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE) - && let Some(tex) = app.renderer.variant(job) - { - ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE); - } - text_x = tr.right() + 10.0; - } - ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); - if fav { - paint( - ui.painter(), - Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)), - Icon::StarFilled, - t.star, - ); - } - // resting on a preset previews it in the loupe (no history entry) - if resp.hovered() - && let Some(s) = look - { - let label = crate::i18n::tr_format!("Preset: {name}", name = name); - app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s }); - if let Some(id) = active { - ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label))); - } - } - if resp.clicked() { - let _ = app.run("preset.apply", json!({"id": pid, "amount": 100})); - ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0))); - ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); - app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); - } - let (builtin, group) = (pr.builtin, pr.group.clone()); - resp.context_menu(|ui| { - if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() { - let _ = app.run("preset.favorite", json!({"id": pid})); - } - if builtin { - return; - } - ui.separator(); - if ui - .add_enabled(app.session.active().is_some(), egui::Button::new(crate::i18n::tr("Update with Current Settings"))) - .clicked() - { - match app.run("preset.update", json!({"id": pid})) { - Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)), - Err(e) => app.toast(ui.ctx(), e), - } - } - if ui.button(crate::i18n::tr("Rename…")).clicked() { - crate::panels::dialogs::prompt( - app, - "Rename Preset", - "Preset name", - &name, - "preset.rename", - json!({"id": pid}), - "name", - ); - } - ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { - let mut groups: Vec = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect(); - groups.sort(); - groups.dedup(); - for g in groups.iter().filter(|g| **g != group) { - if ui.button(g).clicked() { - let _ = app.run("preset.move", json!({"id": pid, "group": g})); - } - } - if ui.button(crate::i18n::tr("New Group…")).clicked() { - crate::panels::dialogs::prompt( - app, - "Move Preset to New Group", - "Group name", - "", - "preset.move", - json!({"id": pid}), - "group", - ); - } - }); - ui.separator(); - if ui.button(crate::i18n::tr("Delete Preset")).clicked() { - let _ = app.run("preset.delete", json!({"id": pid})); - } - }); - } - } - ui.add_space(30.0); - }); - let last_hover_id = egui::Id::new("last-preset-hover"); - let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos)); - if pointer_in_scroll { - if app.hover_preview.is_none() - && let (Some(id), Some(curr)) = (active, current.as_ref()) - && let Some((saved_id, saved_preset, saved_label)) = - ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id)) - && saved_id == id - { - app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) }); - } - } else { - ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id)); + let any_user = app.session.presets.iter().any(|p| !p.builtin); + if ui.add_enabled(any_user, egui::Button::new(crate::i18n::tr("Export User Presets…"))).clicked() { + let _ = app.run("file.exportPresets", json!({})); } }); + divider(ui); + // amount slider (applies to the last applied preset) + let amt_id = egui::Id::new("preset-amount"); + let last: Option<(String, f64)> = ui.data(|d| d.get_temp(amt_id)); + if let Some((pid, amount)) = last.clone() { + let spec = ControlSpec { + id: "presetAmount", + label: "Amount", + section: Section::Profile, + min: 0.0, + max: 200.0, + default: 100.0, + step: 1.0, + decimals: 0, + track: Track::Plain, + }; + let out = slider(ui, &spec, amount, true, None); + if let Some(v) = out.value { + // re-apply from the pre-preset state: undo last preset step then apply with the new amount + let _ = app.run("edit.undo", json!({})); + let _ = app.run("preset.apply", json!({"id": pid, "amount": v})); + ui.data_mut(|d| d.insert_temp(amt_id, (pid, v))); + } + divider(ui); + } + let active = app.session.active(); + let current = active.and_then(|id| app.session.develop_of(id)).map(|d| (*d).clone()); + let thumbs = app.ui.preset_thumbs && active.is_some(); + let row_h = if thumbs { 54.0 } else { 26.0 }; + let scroll = egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + let mut groups: BTreeMap> = BTreeMap::new(); + for p in &app.session.presets { + groups.entry(p.group.clone()).or_default().push(p.clone()); + } + for (g, items) in groups { + // A group holding stock presets is a stock group (shown in the UI language), even + // when user presets share its name; a group of user presets keeps its name. + let builtin = items.iter().any(|p| p.builtin); + let open_id = egui::Id::new(("preset-group", &g)); + let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + register(ui.ctx(), format!("presetGroup:{g}"), r); + paint( + ui.painter(), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + if open { Icon::ChevronDown } else { Icon::ChevronRight }, + t.text_label, + ); + ui.painter().text( + pos2(r.left() + 36.0, r.center().y), + Align2::LEFT_CENTER, + crate::i18n::builtin_label(&g, builtin), + t.semibold(13.0), + t.text_label, + ); + if resp.clicked() { + ui.data_mut(|d| d.insert_temp(open_id, !open)); + } + resp.context_menu(|ui| { + if ui.button(crate::i18n::tr("Export Group…")).clicked() { + let _ = app.run("file.exportPresets", json!({"group": g})); + } + }); + if !open { + continue; + } + for pr in items { + let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); + register(ui.ctx(), format!("preset:{pid}"), r); + let is_last = last.as_ref().is_some_and(|(l, _)| *l == pid); + if is_last { + ui.painter().rect_filled(r, 0.0, t.tool_active); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7)); + } + let look = if thumbs || resp.hovered() { current.as_ref().map(|d| pr.apply(d, 1.0)) } else { None }; + let mut text_x = r.left() + 40.0; + if thumbs && let (Some(id), Some(s)) = (active, look.as_ref()) { + let tr = Rect::from_min_size(pos2(r.left() + 36.0, r.top() + 5.0), vec2(66.0, row_h - 10.0)); + ui.painter().rect_filled(tr, 2.0, t.canvas); + if let Some(job) = app.session.variant_job(id, s, THUMB_EDGE) + && let Some(tex) = app.renderer.variant(job) + { + ui.painter().image(tex.tex.id(), tr, cover_uv(tr, tex.size), Color32::WHITE); + } + text_x = tr.right() + 10.0; + } + ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); + if fav { + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)), Icon::StarFilled, t.star); + } + // resting on a preset previews it in the loupe (no history entry) + if resp.hovered() + && let Some(s) = look + { + let label = crate::i18n::tr_format!("Preset: {name}", name = name); + app.hover_preview = Some(HoverPreview { label: label.clone(), settings: s }); + if let Some(id) = active { + ui.data_mut(|d| d.insert_temp(egui::Id::new("last-preset-hover"), (id, pr.clone(), label))); + } + } + if resp.clicked() { + let _ = app.run("preset.apply", json!({"id": pid, "amount": 100})); + ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0))); + ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(egui::Id::new("last-preset-hover"))); + app.toast(ui.ctx(), crate::i18n::tr_format!("Preset: {name}", name = name)); + } + let (builtin, group) = (pr.builtin, pr.group.clone()); + resp.context_menu(|ui| { + if ui.button(crate::i18n::tr(if fav { "Remove from Favorites" } else { "Add to Favorites" })).clicked() { + let _ = app.run("preset.favorite", json!({"id": pid})); + } + if builtin { + return; + } + ui.separator(); + if ui + .add_enabled(app.session.active().is_some(), egui::Button::new(crate::i18n::tr("Update with Current Settings"))) + .clicked() + { + match app.run("preset.update", json!({"id": pid})) { + Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Updated “{name}”", name = name)), + Err(e) => app.toast(ui.ctx(), e), + } + } + if ui.button(crate::i18n::tr("Rename…")).clicked() { + crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name"); + } + ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { + let mut groups: Vec = app.session.presets.iter().filter(|p| !p.builtin).map(|p| p.group.clone()).collect(); + groups.sort(); + groups.dedup(); + for g in groups.iter().filter(|g| **g != group) { + if ui.button(g).clicked() { + let _ = app.run("preset.move", json!({"id": pid, "group": g})); + } + } + if ui.button(crate::i18n::tr("New Group…")).clicked() { + crate::panels::dialogs::prompt( + app, + "Move Preset to New Group", + "Group name", + "", + "preset.move", + json!({"id": pid}), + "group", + ); + } + }); + ui.separator(); + if ui.button(crate::i18n::tr("Delete Preset")).clicked() { + let _ = app.run("preset.delete", json!({"id": pid})); + } + }); + } + } + ui.add_space(30.0); + }); + let last_hover_id = egui::Id::new("last-preset-hover"); + let pointer_in_scroll = ui.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|pos| scroll.inner_rect.contains(pos)); + if pointer_in_scroll { + if app.hover_preview.is_none() + && let (Some(id), Some(curr)) = (active, current.as_ref()) + && let Some((saved_id, saved_preset, saved_label)) = + ui.data(|d| d.get_temp::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id)) + && saved_id == id + { + app.hover_preview = Some(HoverPreview { label: saved_label, settings: saved_preset.apply(curr, 1.0) }); + } + } else { + ui.data_mut(|d| d.remove::<(lightcraft_catalog::PhotoId, Preset, String)>(last_hover_id)); + } + }); } /// The part of a `size` image that covers `r` (centre crop), as texture coordinates. diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index ff6bc401..1c5a04ad 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -14,10 +14,7 @@ use crate::widgets::{divider, register, slider, text_button}; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); - // the presets column and the left sidebar are laid out after this panel: leave them their room - let reserve = if app.ui.presets { t.panel_w } else { 0.0 } + if app.ui.left_panel { crate::state::LEFT_WIDTH.min } else { 0.0 }; - let width = app.ui.right_width; - let resized = super::resizable_side(ui, false, "right_panel", frame, width, crate::state::RIGHT_WIDTH, reserve, |ui| { + frame.show(ui, |ui| { let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection if app.ui.right == RightPanel::Keywords { @@ -52,9 +49,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); }); - if let Some(w) = resized { - app.ui.right_width = w; - } } pub fn header(ui: &mut egui::Ui, title: &str) { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2abea5fa..7771ffb5 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -300,12 +300,33 @@ fn grid_bracket_command(keymap: &Keymap, grid: bool, id: &'static str, shortcut: if keymap.iter().any(|(other, sc)| other != id && find_bindable(other).is_some() && parse(sc) == Some(shortcut)) { None } else { Some(command) } } +/// Content widgets keep keyboard ownership, including text fields and arrow-driven sliders. +/// Dock chrome owns activation, traversal, resizing and cancellation; its other keys reach commands. +fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { + if !ctx.egui_wants_keyboard_input() { + return false; + } + let chrome = app + .ui + .docking + .as_ref() + .is_some_and(|workspace| craft_ui::docking::DockArea::new(egui::Id::new("lightcraft-docking")).chrome_has_focus(ctx, &workspace.layout)); + if !chrome { + return true; + } + ctx.input(|input| { + [Key::Enter, Key::Space, Key::Tab, Key::Escape, Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown] + .iter() + .any(|key| input.key_pressed(*key)) + }) +} + pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) { app.recording_shortcut = None; } // don't steal keys from text fields or from the keymap editor recording a shortcut - if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() { + if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() { return; } let mut fire: Vec = Vec::new(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b178a167..6a50e2ba 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -280,6 +280,8 @@ pub enum KeywordingView { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + /// Shared panel arrangement; absent in legacy settings until their first layout. + pub docking: Option, #[serde(default = "crate::i18n::default_language")] pub language: crate::i18n::Locale, /// The Build Previews run last announced (its identity, finished?). @@ -780,6 +782,7 @@ pub struct SyncCounts { impl Default for UiState { fn default() -> Self { UiState { + docking: None, language: crate::i18n::default_language(), preview_build_seen: None, unsaved_seen: false, diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs new file mode 100644 index 00000000..0e3c499a --- /dev/null +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -0,0 +1,334 @@ +//! Dock changes exercise the same real catalog and command handlers as the application. +use crate::{LightcraftApp, Services, docking::Panel, headless::Headless, state::RightPanel}; +use egui::{Event, Modifiers, PointerButton}; +use serde_json::json; + +fn app() -> Headless { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.left_panel = true; + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + for _ in 0..5 { + h.step(); + } + h +} +fn dock(h: &mut Headless, p: serde_json::Value) { + h.app.run("ui.dock", p).unwrap(); + for _ in 0..4 { + h.step(); + } +} +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(key, _)| key == id).unwrap().1 +} +fn release(h: &mut Headless, pos: egui::Pos2) { + h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); +} + +#[test] +fn inactive_same_mode_commands_reveal_without_closing_or_editing() { + let mut h = app(); + dock(&mut h, json!({"operation":"move","panel":"tools","target":"library","zone":"center"})); + for command in ["panel.crop", "panel.crop", "tool.brush", "section.light", "panel.profiles"] { + dock(&mut h, json!({"operation":"activate","panel":"library"})); + let undo = h.app.session.undo.len(); + let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); + h.app.run(command, json!({})).unwrap(); + assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools), "{command}"); + assert_ne!(h.app.ui.right, RightPanel::None); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(), photos); + h.step(); + } + h.app.ui.presets = true; + h.step(); + dock(&mut h, json!({"operation":"move","panel":"presets","target":"library","zone":"center"})); + dock(&mut h, json!({"operation":"activate","panel":"library"})); + h.app.run("panel.presets", json!({})).unwrap(); + assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets)); + dock(&mut h, json!({"operation":"activate","panel":"library"})); + dock(&mut h, json!({"operation":"open","panel":"presets"})); + assert!(h.app.ui.docking.as_ref().unwrap().exposed(Panel::Presets)); +} + +#[test] +fn crop_canvas_double_click_reveals_edit_in_actual_custom_workspace() { + let mut h = app(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.run("panel.crop", json!({})).unwrap(); + assert!(h.settle(std::time::Duration::from_secs(120)), "the demo photo settles before pointer input"); + h.app.ui.presets = true; + h.step(); + dock(&mut h, json!({"operation":"move","panel":"tools","target":"presets","zone":"center"})); + dock(&mut h, json!({"operation":"activate","panel":"presets"})); + assert_eq!(h.app.ui.right, RightPanel::Crop); + assert!(!h.app.ui.docking.as_ref().unwrap().exposed(Panel::Tools)); + let library = h.app.ui.docking.as_ref().unwrap().layout.location(&Panel::Library).unwrap(); + let id = h.app.session.active().unwrap(); + let photo = serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap(); + let undo = h.app.session.undo.len(); + let center = h.app.image_rect.expect("crop photo is rendered").center(); + release(&mut h, center); + release(&mut h, center); + for _ in 0..3 { + h.step(); + } + assert_eq!(h.app.ui.right, RightPanel::Edit, "the real Canvas double-click completes Crop"); + let workspace = h.app.ui.docking.as_ref().unwrap(); + assert!(workspace.exposed(Panel::Tools), "tool.done reveals Tools after the Canvas callback"); + assert_eq!(workspace.layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Presets)); + assert_eq!(workspace.layout.location(&Panel::Library).unwrap(), library); + assert_eq!(h.app.session.active(), Some(id)); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(serde_json::to_value(h.app.session.catalog.photo(id).unwrap().as_ref()).unwrap(), photo); +} + +#[test] +fn floating_panel_blocks_photo_album_drop_then_uncovered_target_adds_exact_selection() { + let mut h = app(); + let album = h.app.run("album.create", json!({"name":"A Dock Target", "addSelected":false})).unwrap()["id"].as_u64().unwrap(); + h.step(); + h.step(); + let row = rect(&h, &format!("source:album:{album}")); + let ids: Vec<_> = h.app.session.visible_cloned().iter().take(2).map(|id| id.0).collect(); + dock(&mut h, json!({"operation":"float","panel":"tools","rect":[row.left()-10.0,row.top()-30.0,330,500]})); + let undo = h.app.session.undo.len(); + h.app.ui.dragging_photos = Some(ids.clone()); + release(&mut h, row.center()); + assert!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos.is_empty()); + assert_eq!(h.app.session.undo.len(), undo); + dock(&mut h, json!({"operation":"moveFloating","panel":"tools","rect":[800,100,330,500]})); + h.app.ui.dragging_photos = Some(ids.clone()); + release(&mut h, row.center()); + assert_eq!( + h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).unwrap().photos, + ids.into_iter().map(lightcraft_catalog::PhotoId).collect::>() + ); + assert_eq!(h.app.session.undo.len(), undo + 1); +} + +#[test] +fn preset_hover_survives_float_movement_without_mutating_photo() { + let mut h = app(); + h.app.run("panel.presets", json!({})).unwrap(); + dock(&mut h, json!({"operation":"float","panel":"presets","rect":[500,100,300,700]})); + let undo = h.app.session.undo.len(); + let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); + for x in [500, 650] { + dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[x,100,300,700]})); + let row = h.app.widgets.iter().find(|(id, _)| id.starts_with("preset:")).unwrap().1; + h.app.synthetic.push(Event::PointerMoved(row.center())); + h.step(); + h.step(); + assert!(h.app.hover_preview.is_some()); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(), photos); + } + h.app.synthetic.push(Event::PointerMoved(egui::pos2(5.0, 5.0))); + h.step(); + h.step(); + assert!(h.app.hover_preview.is_none()); +} + +fn key(h: &mut Headless, key: egui::Key) { + h.app.synthetic.push(Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); + h.app.synthetic.push(Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +#[test] +fn dock_tab_keeps_activation_keys_and_global_command_remains_reachable() { + let mut h = app(); + h.app.run("app.setShortcut", json!({"id":"panel.crop","shortcut":"K"})).unwrap(); + dock(&mut h, json!({"operation":"move","panel":"tools","target":"library","zone":"center"})); + h.app.run("panel.crop", json!({})).unwrap(); + h.step(); + // The test chooses the renderer's actual registered tab, rather than an arbitrary focus owner. + let id = egui::Id::new("lightcraft-docking").with(("tab", Panel::Tools)); + h.view.ctx.memory_mut(|memory| memory.request_focus(id)); + h.step(); + key(&mut h, egui::Key::Enter); + assert_eq!(h.app.ui.right, RightPanel::Crop, "Enter activates the tab without tool.done"); + key(&mut h, egui::Key::K); + assert_eq!(h.app.ui.right, RightPanel::None, "the global crop toggle is reachable from tab focus"); +} + +#[test] +fn content_text_edit_retains_command_keys_and_typed_text() { + let mut h = app(); + h.app.run("app.setShortcut", json!({"id":"panel.crop","shortcut":"K"})).unwrap(); + h.app.run("panel.keywords", json!({})).unwrap(); + h.step(); + h.step(); + let result = h.request("ui.clickWidget", json!({"id":"field:keywordFilter"}), std::time::Duration::from_secs(20)); + assert_eq!(result["ok"], true, "{result}"); + key(&mut h, egui::Key::K); + h.app.synthetic.push(Event::Text("dock search".into())); + h.step(); + assert_eq!(h.app.ui.right, RightPanel::Keywords); + assert_eq!(h.view.ctx.data(|data| data.get_temp::(egui::Id::new("keyword-list-filter"))).as_deref(), Some("dock search")); +} + +#[test] +fn floating_presets_block_keyword_photo_drop_then_uncovered_target_changes_exact_metadata() { + let mut h = app(); + h.app.run("panel.keywords", json!({})).unwrap(); + h.app.run("keyword.create", json!({"name":"A Dock Keyword"})).unwrap(); + h.step(); + h.step(); + let row = rect(&h, "keywordRow:A Dock Keyword"); + h.app.ui.presets = true; + h.step(); + let ids: Vec<_> = h.app.session.visible_cloned().iter().take(2).map(|id| id.0).collect(); + dock(&mut h, json!({"operation":"float","panel":"presets","rect":[row.left()-10.0,row.top()-30.0,300,500]})); + let undo = h.app.session.undo.len(); + let before: Vec<_> = ids.iter().map(|id| h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords.clone()).collect(); + h.app.ui.dragging_photos = Some(ids.clone()); + release(&mut h, row.center()); + for (id, expected) in ids.iter().zip(&before) { + assert_eq!(&h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords, expected); + } + assert_eq!(h.app.session.undo.len(), undo); + dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[400,100,300,500]})); + h.app.ui.dragging_photos = Some(ids.clone()); + release(&mut h, row.center()); + for (id, mut expected) in ids.iter().zip(before) { + expected.push("A Dock Keyword".into()); + let mut actual = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(*id)).unwrap().meta.keywords.clone(); + expected.sort(); + actual.sort(); + assert_eq!(actual, expected); + } + assert_eq!(h.app.session.undo.len(), undo + 1); +} + +#[test] +fn covered_folder_rejects_album_move_and_uncovered_folder_nests_exact_album() { + let mut h = app(); + let folder = h.app.run("album.create", json!({"name":"A Dock Folder","folder":true})).unwrap()["id"].as_u64().unwrap(); + let child = h.app.run("album.create", json!({"name":"B Dock Album","addSelected":false})).unwrap()["id"].as_u64().unwrap(); + h.step(); + h.step(); + let row = rect(&h, &format!("source:folder:{folder}")); + dock(&mut h, json!({"operation":"float","panel":"tools","rect":[row.left()-10.0,row.top()-30.0,330,500]})); + let undo = h.app.session.undo.len(); + h.app.ui.dragging_album = Some(child); + release(&mut h, row.center()); + assert_eq!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(child)).unwrap().parent, None); + assert_eq!(h.app.session.undo.len(), undo); + dock(&mut h, json!({"operation":"moveFloating","panel":"tools","rect":[800,100,330,500]})); + h.app.ui.dragging_album = Some(child); + release(&mut h, row.center()); + assert_eq!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(child)).unwrap().parent, Some(lightcraft_catalog::AlbumId(folder))); + assert_eq!(h.app.session.undo.len(), undo + 1); +} + +#[test] +fn covered_keyword_rejects_hierarchy_move_and_uncovered_target_moves_exact_keyword() { + let mut h = app(); + h.app.run("panel.keywords", json!({})).unwrap(); + h.app.run("keyword.create", json!({"name":"A Dock Parent"})).unwrap(); + h.app.run("keyword.create", json!({"name":"B Dock Child"})).unwrap(); + h.app.ui.presets = true; + h.step(); + h.step(); + let row = rect(&h, "keywordRow:A Dock Parent"); + dock(&mut h, json!({"operation":"float","panel":"presets","rect":[row.left()-10.0,row.top()-30.0,300,500]})); + let undo = h.app.session.undo.len(); + h.app.ui.dragging_keyword = Some("B Dock Child".into()); + release(&mut h, row.center()); + assert!(h.app.session.catalog.has_keyword("B Dock Child")); + assert!(!h.app.session.catalog.has_keyword("A Dock Parent|B Dock Child")); + assert_eq!(h.app.session.undo.len(), undo); + dock(&mut h, json!({"operation":"moveFloating","panel":"presets","rect":[400,100,300,500]})); + h.app.ui.dragging_keyword = Some("B Dock Child".into()); + release(&mut h, row.center()); + assert!(!h.app.session.catalog.has_keyword("B Dock Child")); + assert!(h.app.session.catalog.has_keyword("A Dock Parent|B Dock Child")); + assert_eq!(h.app.session.undo.len(), undo + 1); +} + +#[test] +fn reset_during_held_divider_drag_keeps_replacement_layout_on_later_movement() { + let mut h = app(); + let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); + let from = h.view.ctx.read_response(divider).expect("the custom workspace root divider is rendered").rect.center(); + let before = h.app.ui.docking.as_ref().unwrap().layout.clone(); + h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(40.0, 0.0))); + h.step(); + assert_ne!(h.app.ui.docking.as_ref().unwrap().layout, before, "fixture performs a real divider resize"); + assert_eq!(h.view.ctx.dragged_id(), Some(divider)); + let resized = h.app.ui.docking.as_ref().unwrap().layout.clone(); + h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(55.0, 0.0))); + h.step(); + assert_ne!(h.app.ui.docking.as_ref().unwrap().layout, resized, "renderer-generated actions preserve continuous dragging"); + let undo = h.app.session.undo.len(); + h.app.run("ui.dock.reset", json!({})).unwrap(); + let reset = serde_json::to_value(&h.app.ui.docking).unwrap(); + h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(90.0, 0.0))); + h.step(); + assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), reset, "a held old divider cannot resize the replacement tree"); + h.app.synthetic.push(Event::PointerButton { + pos: from + egui::vec2(90.0, 0.0), + button: PointerButton::Primary, + pressed: false, + modifiers: Modifiers::NONE, + }); + h.step(); + assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), reset); + assert_eq!(h.app.session.undo.len(), undo); +} + +fn held_divider_replacement_route(route: &str) { + let mut h = app(); + let divider = egui::Id::new("lightcraft-docking").with(("split", Vec::::new())); + let from = h.view.ctx.read_response(divider).unwrap().rect.center(); + let photos = serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(); + h.app.synthetic.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(40.0, 0.0))); + h.step(); + assert_eq!(h.view.ctx.dragged_id(), Some(divider)); + let undo = h.app.session.undo.len(); + match route { + "ui.set" => { + let response = h.request("ui.set", json!({"rightWidth":410.0}), std::time::Duration::from_secs(20)); + assert_eq!(response["ok"], true, "{response}"); + assert_eq!(h.app.ui.right_width, 410.0); + } + "panel.close" => { + h.app.run("panel.close", json!({})).unwrap(); + h.step(); + } + "public-action" => { + let action = craft_ui::docking::Action::Float { panel: Panel::Tools, rect: [800.0, 100.0, 330.0, 500.0] }; + h.app.run("ui.dock", json!({"action":action})).unwrap(); + h.step(); + } + _ => panic!("unknown test route"), + } + let replacement = serde_json::to_value(&h.app.ui.docking).unwrap(); + h.app.synthetic.push(Event::PointerMoved(from + egui::vec2(90.0, 0.0))); + h.step(); + assert_eq!(serde_json::to_value(&h.app.ui.docking).unwrap(), replacement, "{route} cancels the previous divider gesture"); + assert_eq!(h.app.session.undo.len(), undo); + assert_eq!(serde_json::to_value(h.app.session.catalog.photos().map(|photo| photo.as_ref()).collect::>()).unwrap(), photos); +} +#[test] +fn actual_ui_set_width_replacement_cancels_held_divider() { + held_divider_replacement_route("ui.set"); +} +#[test] +fn panel_close_sync_replacement_cancels_held_divider() { + held_divider_replacement_route("panel.close"); +} +#[test] +fn externally_serialized_action_replacement_cancels_held_divider() { + held_divider_replacement_route("public-action"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index c748ecf1..b95fa1ab 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -143,6 +143,16 @@ fn mask_adjustments_hide_overlay_without_disabling_the_mask() { // Changing the shape needs the overlay; changing the image needs an unobscured preview. exec(&mut h, "mask.refine", json!({"value": 10.0})); assert!(h.app.ui.mask_overlay); + // The dock header pushes this adjustment below the initial viewport. Exercise the + // actual visible slider rather than sending a synthetic click outside its clipped body. + let panel = h.app.widgets.iter().find(|(id, _)| id == "panel:right_panel").unwrap().1; + let slider = h.app.widgets.iter().find(|(id, _)| id == "slider:exposure").unwrap().1; + assert!(!panel.contains_rect(slider), "exposure initially needs scrolling: {slider:?} in {panel:?}"); + h.request("ui.move", json!({"x": panel.center().x, "y": panel.center().y}), T); + h.request("ui.scroll", json!({"dy": -160.0}), T); + h.settle(SETTLE); + let slider = h.app.widgets.iter().find(|(id, _)| id == "slider:exposure").unwrap().1; + assert!(panel.contains_rect(slider), "exposure is visible before clicking: {slider:?} in {panel:?}"); let r = h.request("ui.clickWidget", json!({"id": "slider:exposure", "fx": 0.7}), T); assert_eq!(r["ok"], true, "{r}"); assert!(!h.app.ui.mask_overlay, "the exposure slider hides the overlay after release"); diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index c620ee0a..d9e5dfcf 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -194,7 +194,9 @@ impl<'a> TextField<'a> { let right = ui.input(|i| i.pointer.button_down(egui::PointerButton::Secondary) || i.pointer.button_released(egui::PointerButton::Secondary)); let selection = egui::text_edit::TextEditState::load(ui.ctx(), id).and_then(|s| s.cursor.char_range()); let mut response = ui.add(edit); - if right && response.contains_pointer() { + // The menu may take focus on a primary-button release. egui collapses a + // TextEdit selection when focus leaves; retain the range our own menu acts on. + if right && response.contains_pointer() || memo.menu_open && !ui.memory(|m| m.has_focus(id)) { let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); state.cursor.set_char_range(selection); state.store(ui.ctx(), id); @@ -514,6 +516,20 @@ mod tests { assert_eq!(rig.text, "Kyoto"); } + #[test] + fn clicking_back_into_field_dismisses_its_menu_and_places_a_new_caret() { + let mut rig = Rig::new("Lisbon"); + rig.select_all_by_keys(); + rig.click(FIELD, PointerButton::Secondary); + let rect = rig.rect(FIELD).unwrap(); + rig.click_at(egui::pos2(rect.left() + 2.0, rect.center().y), PointerButton::Primary); + let range = egui::text_edit::TextEditState::load(&rig.view.ctx, id(FIELD)).unwrap().cursor.char_range().unwrap(); + assert!(range.is_empty(), "a click back in the field places a caret: {range:?}"); + rig.type_text("X"); + assert_eq!(rig.text, "XLisbon"); + assert!(rig.endings.is_empty(), "dismissing into the field keeps the edit open"); + } + /// With nothing selected, Cut and Copy are greyed out: clicking one does nothing, as in a native /// menu, and neither closes the menu nor ends the edit. #[test] diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0d7e1570..620871c3 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -237,18 +237,11 @@ pub fn apply(ctx: &egui::Context) { v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) }; v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) }; let w = &mut v.widgets; - for (wv, fill) in [ - (&mut w.noninteractive, t.chrome), - (&mut w.inactive, t.button), - (&mut w.hovered, t.hover), - (&mut w.active, t.pressed), - (&mut w.open, t.hover), - ] { - wv.bg_fill = fill; - wv.weak_bg_fill = fill; - wv.corner_radius = CornerRadius::same(4); - wv.fg_stroke = Stroke::new(1.0, t.text_label); - } + craft_ui::theme::WidgetPalette { noninteractive: t.chrome, inactive: t.button, hovered: t.hover, active: t.pressed, open: t.hover }.apply_to( + w, + CornerRadius::same(4), + Stroke::new(1.0, t.text_label), + ); w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); w.inactive.bg_stroke = Stroke::new(1.0, t.button_border); ctx.set_visuals(v); diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/docking_workflows.rs new file mode 100644 index 00000000..aa80e830 --- /dev/null +++ b/crates/ui-egui/tests/docking_workflows.rs @@ -0,0 +1,126 @@ +//! Real library/tool panel moves preserve the selected tool and saved arrangement. +use egui::{Event, Modifiers, PointerButton, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use lightcraft_ui_egui::{LightcraftApp, Services, UiState, docking::Panel, state::RightPanel}; +use serde_json::json; +use std::time::{Duration, Instant}; + +fn harness(state: Option, width: f32, scale: f32) -> Harness<'static, LightcraftApp> { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + if let Some(state) = state { + app.ui = state; + } else { + app.ui.left_panel = true; + app.ui.right = RightPanel::Edit; + } + let mut h = Harness::builder().with_size(vec2(width, 900.0)).with_pixels_per_point(scale).build_ui_state( + |ui, app: &mut LightcraftApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(8); + h +} + +fn drag(h: &mut Harness<'_, LightcraftApp>, from: egui::Pos2, to: egui::Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(2); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + for step in 1..=8 { + h.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); +} + +fn settle_photo(h: &mut Harness<'_, LightcraftApp>) { + let started = Instant::now(); + let mut quiet = 0; + while quiet < 4 { + h.run_steps(2); + if h.state().renderer.in_flight() == 0 && h.state().loupe_shown.is_some_and(|(_, source)| source == "render") { + quiet += 1; + } else { + quiet = 0; + } + assert!(started.elapsed() < Duration::from_secs(30), "the synthetic photo and panel thumbnails must finish rendering before capture"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn real_drag_float_close_reopen_and_state_reload_keep_photo_tool_mode() { + let mut h = harness(None, 1400.0, 1.0); + let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center(); + let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Library").rect().center(); + drag(&mut h, source, target); + assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.location(&Panel::Tools).unwrap().anchor, Some(Panel::Library)); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary); + h.run_steps(3); + h.get_by_label("Float panel").click(); + h.run_steps(5); + let rect = h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect; + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click_button(PointerButton::Secondary); + h.run_steps(3); + h.get_by_label("Close panel").click(); + h.run_steps(4); + assert_eq!(h.state().ui.right, RightPanel::None); + h.state_mut().ui.right = RightPanel::Crop; + h.run_steps(4); + assert_eq!(h.state().ui.docking.as_ref().unwrap().layout.floating.iter().find(|g| g.panels.contains(&Panel::Tools)).unwrap().rect, rect); + let saved = serde_json::to_string(&h.state().ui).unwrap(); + let expected = serde_json::to_value(&h.state().ui.docking).unwrap(); + let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0); + assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected); + assert_eq!(reloaded.state().ui.right, RightPanel::Crop); + reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"tools","target":"library","zone":"center"})).unwrap(); + reloaded.run_steps(4); + assert_eq!(reloaded.state().ui.right, RightPanel::Crop); + reloaded.state_mut().run("ui.dock", json!({"operation":"close","panel":"library"})).unwrap(); + reloaded.run_steps(3); + assert!(!reloaded.state().ui.left_panel); + assert_eq!(reloaded.state().ui.right, RightPanel::Crop); +} + +#[test] +fn invalid_docking_requests_preserve_layout_and_selected_tool() { + let mut h = harness(None, 1400.0, 1.0); + let before = serde_json::to_value(&h.state().ui.docking).unwrap(); + for params in [ + json!({"operation":"close","panel":"canvas"}), + json!({"operation":"move","panel":"tools","target":"canvas","zone":"center"}), + json!({"operation":"float","panel":"tools","rect":[0,0,-1,100]}), + json!({"operation":"move","panel":"unknown","target":"library","zone":"center"}), + ] { + assert!(h.state_mut().run("ui.dock", params).is_err()); + assert_eq!(serde_json::to_value(&h.state().ui.docking).unwrap(), before); + assert_eq!(h.state().ui.right, RightPanel::Edit); + } +} + +#[test] +fn docking_renders_real_photo_panels_default_floating_and_redocked() { + for width in [900.0, 1400.0] { + for scale in [1.0, 2.0] { + let mut h = harness(None, width, scale); + for state in ["default", "floating", "redocked"] { + if state == "floating" { + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"tools","rect":[120,90,320,600]})).unwrap(); + } else if state == "redocked" { + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tools","target":"library","zone":"center"})).unwrap(); + } + h.run_steps(8); + settle_photo(&mut h); + h.state().ui.docking.as_ref().unwrap().validate().unwrap(); + if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(format!("{directory}/light-{width}-{scale}-{state}.png")).unwrap(); + } + } + } + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 552f7695..8abf5a1a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -22,6 +22,34 @@ that hit an error reply should reconnect. The port has no authentication, so onl mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: `crates/ui-egui/src/control.rs` (methods) and `apps/lightcraft/src/control_server.rs` (transport). +## Isolated automation profiles + +Set `LIGHTCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, app logs, the emergency +panic log, the GPU initialization marker, camera profiles and default model folders. The nonempty +value is used as the complete settings directory. Unset retains the normal platform paths; an explicit +empty value disables the settings directory, while the emergency panic log retains its system-temp path. +Explicit model-directory environment variables still take precedence over their defaults. +Also pass `--library DIR` with a separate scratch library: the configuration override does not change +the default photo-library location. Do not use `--memory` or `LIGHTCRAFT_NO_PREFS` when testing persistence. + +A native acceptance run must reject the stderr message `rendering headlessly`: `ui.screenshot` +automatically uses the CPU fallback after two seconds without a presented frame. That fallback is useful +for headless scripts, but cannot establish native-window acceptance. Control input injects egui events; +it does not establish operating-system input delivery or screen-reader acceptance. + +On macOS, the native menu owns its keyboard accelerators. `ui.key` injects egui key events and +does not synthesize the Cocoa menu events for those shortcuts. Use `ui.menu.invoke` with the +command ID (for example `edit.undo`, `edit.redo`, `view.filmstrip` or `view.leftPanel`) to test +their command behavior. That API invokes the app command dispatcher directly; it does not traverse +Cocoa menu dispatch. Native menu activation and keyboard accelerator delivery require a separate test. + +Before capturing a finished Detail view, poll `ui.inspect` until the expected `loupe.photo` is active, +`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty. +For the loupe, `cached` is a prior view render keyed by the current photo and develop settings; +`thumb`, `small` and `embedded` are separate fallback sources. A presented frame can otherwise +contain an in-progress rendering placeholder. Require loaded thumbnail textures only when the +filmstrip is visible. + ## Methods | Method | Params | Result | @@ -127,3 +155,22 @@ one pixel. renders are in flight. Each request runs frames until it is answered and its injected input (clicks, keys, drags) has played out. Widget ids for `ui.clickWidget` come from `ui.widgets` (e.g. `button:upright-auto`). A 1600×1000 demo snapshot takes ~1–2 s (debug build). + +## Panel docking + +Library, Tools, and Presets panels can be regrouped, split, floated within the app window, closed, and reopened. Tools keeps the current Edit/Crop/Masking mode; the photo view and filmstrip stay together. 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: `library`, `tools`, `presets`. The protected center is `canvas`. + +`ui.dock` accepts `operation` and `panel`. 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":"tools","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 051b3aed..4443cb23 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -90,7 +90,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool { /// External crates that constitute a UI toolkit / windowing dependency. /// Entries ending in `*` are prefixes. -pub const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"]; +pub const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"]; /// First layer allowed to use UI crates. pub const UI_MIN_LAYER: u8 = 5; @@ -291,7 +291,7 @@ mod tests { #[test] fn ui_crates_forbidden_below_l5() { - for dep in ["egui", "eframe", "winit", "rfd"] { + for dep in ["craft-ui", "egui", "eframe", "winit", "rfd"] { let v = check(&[c("lightcraft-engine", &[(dep, Normal, false)])]); assert!(matches!(v[..], [Violation::UiBelowL5 { .. }]), "{dep}"); }