From 1e27991cf1c4a557e660ed6e18a02cb2c179871d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:35:36 -0400 Subject: [PATCH 1/9] Title bar: window-gesture policy (drag, double-click zoom, full-screen exempt) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/titlebar.rs | 76 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 crates/ui-egui/src/titlebar.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 502e31ff..43b210f1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -25,6 +25,7 @@ pub mod softpaint; pub mod state; pub mod tasks; pub mod theme; +pub mod titlebar; pub mod widgets; #[cfg(test)] diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs new file mode 100644 index 00000000..40c8975c --- /dev/null +++ b/crates/ui-egui/src/titlebar.rs @@ -0,0 +1,76 @@ +//! The integrated title bar's window gestures (macOS: the native title bar is hidden and the top +//! bar takes its place, so dragging and double-click-to-zoom have to be done here). +//! +//! Pure policy, no egui widgets: [`command_for`] maps a gesture on the bar's empty space and the +//! window's current state to the viewport command the host should run. + +use egui::ViewportCommand; + +/// What the user did on the bar's empty space. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Gesture { + /// A primary-button drag began: the window follows the pointer. + DragStarted, + /// A primary-button double-click: zoom the window, or restore it when already zoomed. + DoubleClicked, +} + +/// The window as the host reports it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WindowState { + /// Zoomed (macOS) / maximized (elsewhere). + pub maximized: bool, + /// In the OS's full-screen mode. + pub fullscreen: bool, +} + +/// The viewport command for `gesture`, if any. Full screen has no title bar to drag or zoom. +pub fn command_for(gesture: Gesture, window: WindowState) -> Option { + if window.fullscreen { + return None; + } + match gesture { + Gesture::DragStarted => Some(ViewportCommand::StartDrag), + Gesture::DoubleClicked => Some(ViewportCommand::Maximized(!window.maximized)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NORMAL: WindowState = WindowState { maximized: false, fullscreen: false }; + const ZOOMED: WindowState = WindowState { maximized: true, fullscreen: false }; + const FULL: WindowState = WindowState { maximized: false, fullscreen: true }; + + // Scenario: dragging the title bar moves the window + #[test] + fn given_a_normal_window_when_dragging_then_the_window_follows_the_pointer() { + assert_eq!(command_for(Gesture::DragStarted, NORMAL), Some(ViewportCommand::StartDrag)); + } + + // Scenario: a zoomed window can still be dragged (macOS lets you pull it free) + #[test] + fn given_a_zoomed_window_when_dragging_then_the_window_follows_the_pointer() { + assert_eq!(command_for(Gesture::DragStarted, ZOOMED), Some(ViewportCommand::StartDrag)); + } + + // Scenario: double-click zooms + #[test] + fn given_a_normal_window_when_double_clicking_then_it_zooms() { + assert_eq!(command_for(Gesture::DoubleClicked, NORMAL), Some(ViewportCommand::Maximized(true))); + } + + // Scenario: double-click again restores + #[test] + fn given_a_zoomed_window_when_double_clicking_then_it_is_restored() { + assert_eq!(command_for(Gesture::DoubleClicked, ZOOMED), Some(ViewportCommand::Maximized(false))); + } + + // Scenario: full screen ignores both + #[test] + fn given_a_full_screen_window_then_neither_gesture_does_anything() { + assert_eq!(command_for(Gesture::DragStarted, FULL), None); + assert_eq!(command_for(Gesture::DoubleClicked, FULL), None); + } +} From 54b037a90a4cf0808a956066358e131295f721da Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:37:05 -0400 Subject: [PATCH 2/9] Headless host: record window commands and report the zoomed state back Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/headless.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 4411c35a..484828e3 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -115,6 +115,10 @@ pub struct Headless { events: Vec, control: Sender, quit: bool, + /// The simulated window is zoomed (`Maximized(true)` seen, reported back to the app like a real host). + pub window_maximized: bool, + /// Window-management commands the app sent (`StartDrag`, `Maximized`, …), oldest first. + pub window_commands: Vec, } impl Headless { @@ -133,6 +137,8 @@ impl Headless { events: vec![], control: tx, quit: false, + window_maximized: false, + window_commands: vec![], } } @@ -148,6 +154,7 @@ impl Headless { /// Run one frame. pub fn step(&mut self) { let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events)); + raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized); self.app.raw_input_hook(&mut raw); let app = &mut self.app; let commands = self.view.run(raw, |ui| { @@ -164,6 +171,11 @@ impl Headless { } ViewportCommand::InnerSize(s) if s.x >= 1.0 && s.y >= 1.0 => self.size = s, ViewportCommand::Close => self.quit = true, + ViewportCommand::Maximized(on) => { + self.window_maximized = on; + self.window_commands.push(c); + } + ViewportCommand::StartDrag => self.window_commands.push(c), _ => {} } } From d01ebd79538bbe53c7246d62919310ec430a9fe1 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:37:05 -0400 Subject: [PATCH 3/9] Top bar: drag to move and double-click to zoom the window (integrated title bar) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/topbar.rs | 25 +++++++ crates/ui-egui/src/tests_titlebar.rs | 107 +++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 crates/ui-egui/src/tests_titlebar.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 43b210f1..b327e245 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -55,6 +55,8 @@ mod tests_scroll; #[cfg(test)] mod tests_switch_library; #[cfg(test)] +mod tests_titlebar; +#[cfg(test)] mod tests_unsaved; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index e960909e..7451c8ac 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -8,6 +8,28 @@ use crate::icons::{Icon, paint}; use crate::theme::Tokens; use crate::widgets::{icon_button, register}; +/// The bar doubles as the window's title bar: dragging its empty space moves the window and a +/// double-click zooms or restores it. Registered before the bar's widgets so they win the click. +fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32) { + use crate::titlebar::{Gesture, WindowState, command_for}; + let bar = Rect::from_min_max(pos2(content.left() - margin_left, content.top()), pos2(content.right() + margin_right, content.bottom())); + register(ui.ctx(), "region:titlebar", bar); + let resp = ui.interact(bar, egui::Id::new("titlebar-handle"), Sense::click_and_drag()); + let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) { + Some(Gesture::DoubleClicked) + } else if resp.drag_started_by(egui::PointerButton::Primary) { + Some(Gesture::DragStarted) + } else { + None + }; + if let Some(g) = gesture { + let window = WindowState { maximized: ui.input(|i| i.viewport().maximized).unwrap_or(false), fullscreen: app.window_is_fullscreen }; + if let Some(cmd) = command_for(g, window) { + ui.ctx().send_viewport_cmd(cmd); + } + } +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let left = if app.integrated_titlebar { 78 } else { 10 }; @@ -16,6 +38,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); + if app.integrated_titlebar { + window_handle(app, ui, full, left as f32, 12.0); + } let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs new file mode 100644 index 00000000..f1ff782e --- /dev/null +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -0,0 +1,107 @@ +//! The integrated title bar behaves like a native one: drag the empty space to move the window, +//! double-click it to zoom and again to restore. Buttons and the search field keep their clicks. + +use std::time::Duration; + +use egui::ViewportCommand; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// Given LightCraft with the integrated title bar (macOS) in a 1200×800 window. +fn integrated() -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.integrated_titlebar = true; + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(Duration::from_secs(120)); + h +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// Empty top-bar space: where the traffic lights sit, left of the sidebar button. +fn empty_spot(h: &Headless) -> egui::Pos2 { + let bar = widget(h, "region:titlebar"); + egui::pos2(bar.left() + 20.0, bar.center().y) +} + +fn play(h: &mut Headless) { + for _ in 0..6 { + h.step(); + } +} + +// Scenario: double-clicking the empty top bar zooms the window, and again restores it +#[test] +fn double_click_on_the_empty_bar_zooms_then_restores() { + let mut h = integrated(); + let p = empty_spot(&h); + h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T); + play(&mut h); + assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true)]); + // a pause longer than egui's multi-click window, or the second pair would count as a triple click + for _ in 0..120 { + h.step(); + } + h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T); + play(&mut h); + assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true), ViewportCommand::Maximized(false)]); +} + +// Scenario: dragging the empty top bar starts a window move +#[test] +fn dragging_the_empty_bar_moves_the_window() { + let mut h = integrated(); + let p = empty_spot(&h); + h.request("ui.drag", json!({"x": p.x, "y": p.y, "toX": p.x + 120.0, "toY": p.y + 40.0}), T); + play(&mut h); + assert_eq!(h.window_commands, vec![ViewportCommand::StartDrag]); +} + +// Scenario: a single click on the empty bar does nothing +#[test] +fn single_click_on_the_empty_bar_does_nothing() { + let mut h = integrated(); + let p = empty_spot(&h); + h.request("ui.click", json!({"x": p.x, "y": p.y}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + +// Scenario: double-clicking a button does not zoom the window +#[test] +fn double_click_on_a_button_does_not_zoom() { + let mut h = integrated(); + let r = widget(&h, "icon:help"); + h.request("ui.click", json!({"x": r.center().x, "y": r.center().y, "count": 2}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + +// Scenario: double-clicking the search field selects a word, it does not zoom the window +#[test] +fn double_click_in_the_search_field_does_not_zoom() { + let mut h = integrated(); + let r = widget(&h, "field:search"); + h.request("ui.click", json!({"x": r.center().x, "y": r.center().y, "count": 2}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + +// Scenario: a window with its native title bar (Windows, Linux) leaves the bar alone +#[test] +fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(Duration::from_secs(120)); + assert!(!h.app.integrated_titlebar); + let bar = h.app.widgets.iter().find(|(w, _)| w == "region:titlebar"); + assert!(bar.is_none()); +} From 86d88de2ee6bf302d0d73a1106cb54df7f7b4982 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:37:55 -0400 Subject: [PATCH 4/9] Parity: track title-bar drag and double-click zoom Co-Authored-By: Claude Sonnet 5.5 --- docs/parity.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index b9c5eed5..b1932654 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | +| X. Cross-cutting behaviours (BEHAV) | 17 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 392 | 35 | 86 | 36 | 193/200 (97%) | 142/152 (93%) | +| **Total** | 392 | 36 | 86 | 36 | 193/200 (97%) | 142/152 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 513 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 160. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 514 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 41.6% of 161. ## Top gaps @@ -522,6 +522,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | +| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). Not checked on a real macOS window yet | ## Y. Menus From d3ceaf0e794b7f80a485d9b538feaad4c955b26d Mon Sep 17 00:00:00 2001 From: ragtux Date: Thu, 8 Oct 2026 18:42:19 -0500 Subject: [PATCH 5/9] Fix #191: commands' file dialogs run off the UI thread The desktop showed rfd's synchronous dialogs on the UI thread, so the window stopped answering the compositor while one was up: GNOME marked LightCraft "not responding", the File menu stayed drawn and the control channel went quiet. Services.picker lets the host start a dialog elsewhere and answer through a channel; a command that needs a path asks for one and returns, and runs again with the answer when the dialog closes (pick.rs). Import Photos, Import from Folder, Open Library, Import Lightroom Catalog, Find Missing Photos, the preset and curve-preset imports and exports, Auto-Tag from Tracklog and Locate Missing File use it; hosts without a picker (the web, tests) keep the synchronous dialogs. ui.inspect lists the waiting commands as fileDialogs. Same cause as storytold/photocraft#574. Co-Authored-By: Claude Fable 5.1 --- apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/src/main.rs | 38 +++- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 9 + crates/ui-egui/src/menus.rs | 112 ++++++++-- crates/ui-egui/src/panels/settings.rs | 18 +- crates/ui-egui/src/pick.rs | 289 ++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 8 files changed, 439 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/pick.rs diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index ede1de0d..16543fd5 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -168,6 +168,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen let (backup_backend, backup_files) = (backend.clone(), files); let restore_backend = backend.clone(); Services { + picker: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; // the photos' own names for the originals in the zip diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 149c98ab..3f86b776 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -281,8 +281,42 @@ fn windows_open_url_command(url: &str) -> std::process::Command { c } -fn services() -> Services { +/// The platform services; `ctx` repaints when a file dialog closes. +fn services(ctx: egui::Context) -> Services { Services { + // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread + // stopped the window answering the compositor, which then reported the app as hung. + picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { + use lightcraft_ui_egui::pick::PickKind; + let (tx, rx) = std::sync::mpsc::channel(); + let repaint = ctx.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + let mut d = rfd::FileDialog::new(); + if let Some(t) = &req.title { + d = d.set_title(t); + } + if let Some((name, exts)) = &req.filter { + d = d.add_filter_nocase(name.clone(), exts); + } + if let Some(n) = &req.file_name { + d = d.set_file_name(n); + } + let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); + let paths: Vec = match req.kind { + PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(), + PickKind::File => d.pick_file().map(s).into_iter().collect(), + PickKind::Folder => d.pick_folder().map(s).into_iter().collect(), + PickKind::Save => d.save_file().map(s).into_iter().collect(), + }; + // the app may have quit meanwhile: nobody to tell + let _ = tx.send(paths); + repaint.request_repaint(); + }) + .map_err(|e| format!("could not start the file dialog: {e}"))?; + Ok(rx) + })), pick_lightroom_catalog: Some(Box::new(|| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) @@ -614,7 +648,7 @@ fn main() -> eframe::Result { std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3"))); // the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); - let mut app = LightcraftApp::new(session, services()); + let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone())); if let Some(ui) = prefs { app.ui = ui; } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7456a724..9f1c0df1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -103,6 +103,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result}, "import": app.import.as_ref().map(crate::import::ImportTask::status), "tasks": app.tasks.labels(), + // commands waiting on a native file dialog shown off the UI thread (`pick`) + "fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::>(), "memory": memory(app), }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 502e31ff..2293a32b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod menubar; pub mod menus; pub mod merge; pub mod panels; +pub mod pick; pub mod render; pub mod shortcuts; pub mod softpaint; @@ -88,6 +89,10 @@ pub type HostAction = Box Result>; /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { + /// Native file dialogs for commands, shown off the UI thread and answered later + /// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside + /// dialogs; without it (the web, tests) they answer the commands too. + pub picker: Option, /// Show an open dialog for photos; returns paths. pub pick_files: Option, /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). @@ -171,6 +176,8 @@ pub struct LightcraftApp { shadow: Option, /// Synthetic input events (from the control channel) injected one step per frame. pub synthetic: Vec, + /// Native file dialogs up for commands (`pick`): each command runs again when its closes. + pub(crate) pending_picks: Vec, /// Modifiers announced for synthetic input (held from a button down to its release). synthetic_mods: egui::Modifiers, /// Clear `synthetic_mods` on the next frame. @@ -249,6 +256,7 @@ impl LightcraftApp { screenshot_token: 0, shadow: None, synthetic: vec![], + pending_picks: vec![], synthetic_mods: egui::Modifiers::NONE, synthetic_mods_release: false, styled: false, @@ -873,6 +881,7 @@ impl LightcraftApp { import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); + pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a4eb6f31..85218fb6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5,6 +5,7 @@ use serde::Serialize; use serde_json::{Value, json}; use crate::LightcraftApp; +use crate::pick::{PickRequest, Picked}; use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; /// (id, label, shortcut, menu path) @@ -1004,7 +1005,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(), + None => { + let req = PickRequest::files(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)), + } + } }; if paths.is_empty() { return Some(Ok(Value::Null)); @@ -1013,8 +1020,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let path = - p.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.services.pick_lightroom_catalog.as_mut().and_then(|f| f())); + let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { + Some(path) => Some(path), + None => { + let req = + PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; match path { Some(path) => app.run("library.importLightroom", json!({"path":path})), None => Ok(Value::Null), @@ -1032,7 +1048,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let folder = match p.get("folder").and_then(Value::as_str) { Some(f) => Some(f.to_string()), - None => app.services.pick_folder.as_mut().and_then(|f| f()), + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, }; let Some(folder) = folder else { return Some(Ok(Value::Null)) }; // the search (checking every photo's file, walking the folder) runs on a worker thread; @@ -1087,7 +1108,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Some(x.to_string()), - None => app.services.pick_tracklog.as_mut().and_then(|f| f().into_iter().next()), + None => { + let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } }; let Some(path) = path else { return Some(Ok(Value::Null)) }; let mut params = p.as_object().cloned().unwrap_or_default(); @@ -1128,7 +1155,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Some(x.to_string()), - None => app.services.pick_files.as_mut().and_then(|f| f().into_iter().next()), + None => { + let req = + PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } }; match path { Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), @@ -1157,7 +1191,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Some(x.to_string()), - None => app.services.pick_folder.as_mut().and_then(|f| f()), + None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, }; match path { Some(path) => crate::import::open(app, vec![path]), @@ -1167,10 +1206,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let paths = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => match app.services.pick_preset_files.as_mut() { - Some(f) => f(), - None => return Some(Err("no file dialog on this platform".into())), - }, + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Presets").into()), + crate::i18n::tr("Presets & Profiles"), + crate::pick::PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } }; if paths.is_empty() { return Some(Ok(Value::Null)); @@ -1249,9 +1296,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Some(x.to_string()), None => { let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets")); - match app.services.save_preset_file.as_mut() { - Some(f) => f(&name), - None => return Some(Err("no file dialog on this platform".into())), + let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], name.clone()); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), } } }; @@ -1272,10 +1321,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let paths: Vec = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => match app.services.pick_curve_preset_files.as_mut() { - Some(f) => f(), - None => return Some(Err("no file dialog on this platform".into())), - }, + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } }; if paths.is_empty() { return Some(Ok(Value::Null)); @@ -1295,10 +1352,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str) { Some(x) => Some(x.to_string()), - None => match app.services.save_curve_preset_file.as_mut() { - Some(f) => f("Point Curves.lccurve"), - None => return Some(Err("no file dialog on this platform".into())), - }, + None => { + let req = PickRequest::save( + crate::i18n::tr("Export Point Curve Presets"), + crate::i18n::tr("Point Curve Presets"), + &["lccurve"], + "Point Curves.lccurve", + ); + match crate::pick::ask(app, id, p, "path", req, |s| { + s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } }; let Some(path) = path else { return Some(Ok(Value::Null)) }; let mut params = json!({"path": path}); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index e16d8815..d87d64ba 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -548,13 +548,17 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result } let path = match p.get("path").and_then(Value::as_str) { Some(x) => x.to_string(), - None => match app.services.pick_folder.as_mut() { - Some(pick) => match pick() { - Some(x) => x, - None => return Ok(Value::Null), - }, - None => return Err("no folder dialog on this platform".into()), - }, + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library")); + match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } }; app.session.close_library().map_err(|e| e.to_string())?; app.session.open_library(&path, false).map_err(|e| e.to_string())?; diff --git a/crates/ui-egui/src/pick.rs b/crates/ui-egui/src/pick.rs new file mode 100644 index 00000000..1032a5cd --- /dev/null +++ b/crates/ui-egui/src/pick.rs @@ -0,0 +1,289 @@ +//! Native file dialogs for commands, off the UI thread (#191). A synchronous dialog blocks the +//! thread that shows it, and on the desktop that was the UI thread: the window stopped answering +//! the compositor (GNOME marked LightCraft "not responding" and offered to kill it), the menu the +//! command came from stayed drawn, and the control channel went quiet until the dialog closed. +//! +//! A host that can run a dialog elsewhere installs a [`Picker`] in `Services`: it starts the +//! dialog and answers through a channel. A command that needs a path the caller didn't give +//! [`ask`]s for one and returns at once; when the dialog closes, [`poll`] runs the command again +//! with the answer filled in under the parameter it was missing. Hosts without a `Picker` (the +//! web, where browser pickers are asynchronous already; tests) answer through the synchronous +//! services as before. + +use crate::{LightcraftApp, Services}; +use serde_json::{Value, json}; +use std::sync::mpsc::{Receiver, TryRecvError}; + +/// What a dialog chooses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PickKind { + /// Any number of files. + Files, + /// One file. + File, + /// One folder. + Folder, + /// Where to save one file. + Save, +} + +/// A dialog to show. Texts are translated already. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickRequest { + pub kind: PickKind, + /// The dialog's title; the host's default when `None`. + pub title: Option, + /// One file-type filter: its name and the extensions it admits (without dots). + pub filter: Option<(String, &'static [&'static str])>, + /// [`PickKind::Save`]: the suggested file name. + pub file_name: Option, +} + +impl PickRequest { + pub fn files(title: Option, filter: impl Into, extensions: &'static [&'static str]) -> Self { + PickRequest { kind: PickKind::Files, title, filter: Some((filter.into(), extensions)), file_name: None } + } + + pub fn file(title: impl Into, filter: impl Into, extensions: &'static [&'static str]) -> Self { + PickRequest { kind: PickKind::File, title: Some(title.into()), filter: Some((filter.into(), extensions)), file_name: None } + } + + pub fn folder(title: impl Into) -> Self { + PickRequest { kind: PickKind::Folder, title: Some(title.into()), filter: None, file_name: None } + } + + pub fn save(title: impl Into, filter: impl Into, extensions: &'static [&'static str], file_name: impl Into) -> Self { + PickRequest { kind: PickKind::Save, title: Some(title.into()), filter: Some((filter.into(), extensions)), file_name: Some(file_name.into()) } + } +} + +/// What File ▸ Import Profiles & Presets… opens. +pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"]; +/// What Import Point Curve Presets… opens. +pub const CURVE_PRESET_EXTENSIONS: &[&str] = &["lccurve", "json"]; + +/// The host's dialog starter: shows the request without blocking and answers through the +/// channel with the chosen paths (none = cancelled). `Err` when no dialog could be started. +pub type Picker = Box Result>, String>>; + +/// A dialog that is up for a command. When it closes, the command runs again with the answer +/// under `key`. +pub struct Pending { + pub command: String, + params: Value, + key: &'static str, + kind: PickKind, + rx: Receiver>, +} + +/// How a command's request for a path was answered. +pub enum Picked { + /// A synchronous dialog answered (an empty list: cancelled). + Now(Vec), + /// The dialog is up (or was already); the command runs again when it closes. + Later, + /// This host has no such dialog. + Unavailable, +} + +/// Ask for a path for `command`, which was run with `params` but without `key`. With a [`Picker`] +/// the dialog runs off the UI thread and the command comes back through [`poll`] with `key` filled +/// in; a dialog already up for the command is not opened twice. Without one, `now` asks the +/// synchronous service. +pub fn ask( + app: &mut LightcraftApp, + command: &str, + params: &Value, + key: &'static str, + request: PickRequest, + now: impl FnOnce(&mut Services) -> Option>, +) -> Picked { + if app.pending_picks.iter().any(|p| p.command == command) { + return Picked::Later; + } + if let Some(start) = app.services.picker.as_mut() { + let kind = request.kind; + match start(request) { + Ok(rx) => { + app.pending_picks.push(Pending { command: command.to_string(), params: params.clone(), key, kind, rx }); + return Picked::Later; + } + Err(e) => log::warn!("{command}: no background file dialog ({e}); asking on the UI thread"), + } + } + match now(&mut app.services) { + Some(paths) => Picked::Now(paths), + None => Picked::Unavailable, + } +} + +/// `params` with the dialog's answer under `key`: the whole list for a `paths` key, the first +/// path otherwise. +pub fn with_answer(params: &Value, key: &str, kind: PickKind, paths: &[String]) -> Value { + let mut p = params.as_object().cloned().unwrap_or_default(); + let answer = match kind { + PickKind::Files => json!(paths), + _ => json!(paths.first().cloned().unwrap_or_default()), + }; + p.insert(key.to_string(), answer); + Value::Object(p) +} + +/// Each frame: run the commands whose dialogs have closed with something chosen; forget the +/// cancelled ones and those whose dialog went away without an answer. +pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.pending_picks.is_empty() { + return; + } + let mut answered = Vec::new(); + let mut waiting = Vec::new(); + for p in std::mem::take(&mut app.pending_picks) { + match p.rx.try_recv() { + Ok(paths) if paths.is_empty() => {} + Ok(paths) => answered.push((p, paths)), + Err(TryRecvError::Empty) => waiting.push(p), + Err(TryRecvError::Disconnected) => log::warn!("{}: the file dialog ended without an answer", p.command), + } + } + if !waiting.is_empty() { + // the host repaints when a dialog closes; this is the net under it + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + app.pending_picks = waiting; + for (p, paths) in answered { + let params = with_answer(&p.params, p.key, p.kind, &paths); + if let Err(e) = app.run(&p.command, params) { + app.toast_error(ctx, e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::headless::Headless; + use crate::state::Dialog; + use std::sync::mpsc::{Sender, channel}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + const SETTLE: Duration = Duration::from_secs(20); + + type Asked = Arc>)>>>; + + /// A windowless app whose host shows dialogs "elsewhere": each request is recorded with the + /// sender that answers it. Frames run through the headless harness, so [`poll`] and the import + /// scan happen as in the app. + fn app_with_picker() -> (Headless, Asked) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + let asked: Asked = Default::default(); + let a = asked.clone(); + app.services.picker = Some(Box::new(move |req| { + let (tx, rx) = channel(); + a.lock().unwrap().push((req, tx)); + Ok(rx) + })); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(SETTLE); + (h, asked) + } + + fn photo_in(dir: &std::path::Path) -> String { + std::fs::create_dir_all(dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + let path = dir.join("one.png"); + std::fs::write(&path, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + path.to_string_lossy().to_string() + } + + /// The import review's sources once frames have run (the review opens after a background + /// scan of the chosen paths). + fn import_sources(h: &mut Headless) -> Option> { + h.settle(SETTLE); + match &h.app.ui.dialog { + Some(Dialog::Import { opts }) => Some(opts.sources.clone()), + _ => None, + } + } + + #[test] + fn import_photos_waits_for_its_dialog_and_runs_with_the_answer() { + let dir = std::env::temp_dir().join(format!("lc-pick-{}", std::process::id())); + let photo = photo_in(&dir); + let (mut h, asked) = app_with_picker(); + // the command returns at once: no review of its own yet, the native dialog is up + assert_eq!(h.app.run("file.addPhotos", json!({})).unwrap(), Value::Null); + assert!(import_sources(&mut h).is_none()); + assert_eq!(h.app.pending_picks.len(), 1); + { + let asked = asked.lock().unwrap(); + assert_eq!(asked.len(), 1); + assert_eq!(asked[0].0.kind, PickKind::Files); + assert_eq!(asked[0].0.filter.as_ref().map(|f| f.1), Some(lightcraft_engine::import::EXTENSIONS)); + } + // asking again while it is up opens no second dialog + assert_eq!(h.app.run("file.addPhotos", json!({})).unwrap(), Value::Null); + h.settle(SETTLE); + assert_eq!(asked.lock().unwrap().len(), 1); + assert_eq!(h.app.pending_picks.len(), 1, "still waiting"); + // the user chooses a photo: the command runs again with it and opens the import review + asked.lock().unwrap()[0].1.send(vec![photo.clone()]).unwrap(); + assert_eq!(import_sources(&mut h), Some(vec![photo])); + assert!(h.app.pending_picks.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_cancelled_or_vanished_dialog_runs_nothing() { + let (mut h, asked) = app_with_picker(); + h.app.run("file.addPhotos", json!({})).unwrap(); + asked.lock().unwrap()[0].1.send(vec![]).unwrap(); + assert!(import_sources(&mut h).is_none(), "cancelled: no import review"); + assert!(h.app.pending_picks.is_empty()); + // the dialog thread ends without answering (the host dropped the sender) + h.app.run("file.addFolder", json!({})).unwrap(); + assert_eq!(h.app.pending_picks.len(), 1); + asked.lock().unwrap().pop(); + assert!(import_sources(&mut h).is_none()); + assert!(h.app.pending_picks.is_empty()); + } + + #[test] + fn a_folder_dialog_fills_in_one_path_and_a_save_dialog_names_the_file() { + let dir = std::env::temp_dir().join(format!("lc-pick-folder-{}", std::process::id())); + let _photo = photo_in(&dir); + let (mut h, asked) = app_with_picker(); + h.app.run("file.addFolder", json!({})).unwrap(); + assert_eq!(asked.lock().unwrap()[0].0.kind, PickKind::Folder); + asked.lock().unwrap()[0].1.send(vec![dir.to_string_lossy().to_string()]).unwrap(); + assert_eq!(import_sources(&mut h), Some(vec![dir.to_string_lossy().to_string()])); + // a save dialog carries the suggested name; the command's other parameters survive + h.app.run("file.exportPresets", json!({"group": "Mine"})).unwrap(); + let req = asked.lock().unwrap().last().map(|(r, _)| r.clone()).unwrap(); + assert_eq!(req.kind, PickKind::Save); + assert_eq!(req.file_name.as_deref(), Some("Mine.lcpreset")); + let p = with_answer(&json!({"group": "Mine"}), "path", PickKind::Save, &["/tmp/Mine.lcpreset".to_string(), "/tmp/other".to_string()]); + assert_eq!(p, json!({"group": "Mine", "path": "/tmp/Mine.lcpreset"})); + assert_eq!(with_answer(&Value::Null, "paths", PickKind::Files, &["a".to_string(), "b".to_string()]), json!({"paths": ["a", "b"]})); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn without_a_picker_the_synchronous_dialog_answers_at_once() { + let dir = std::env::temp_dir().join(format!("lc-pick-sync-{}", std::process::id())); + let photo = photo_in(&dir); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + let p = photo.clone(); + app.services.pick_files = Some(Box::new(move || vec![p.clone()])); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.run("file.addPhotos", json!({})).unwrap(); + assert!(h.app.pending_picks.is_empty()); + assert_eq!(import_sources(&mut h), Some(vec![photo])); + // and a host with neither shows nothing, as before + let mut bare = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + assert_eq!(bare.run("file.addPhotos", json!({})).unwrap(), Value::Null); + assert!(bare.run("file.importPresets", json!({})).is_err(), "no dialog on this platform"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4f02dee8..80fd01a5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) | +| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | From cc8d6174597be553cc97541035261310a2199d50 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:42:52 -0400 Subject: [PATCH 6/9] Top bar: a press that starts on a button or the search field never drags the window Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/topbar.rs | 16 ++++++++++++++-- crates/ui-egui/src/tests_titlebar.rs | 12 +++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 7451c8ac..d06a7559 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -14,10 +14,22 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_l use crate::titlebar::{Gesture, WindowState, command_for}; let bar = Rect::from_min_max(pos2(content.left() - margin_left, content.top()), pos2(content.right() + margin_right, content.bottom())); register(ui.ctx(), "region:titlebar", bar); - let resp = ui.interact(bar, egui::Id::new("titlebar-handle"), Sense::click_and_drag()); + let handle = egui::Id::new("titlebar-handle"); + let resp = ui.interact(bar, handle, Sense::click_and_drag()); + // a press that began on a button or the search field is theirs: dragging off it must not move the window + // (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one) + let ctx = ui.ctx().clone(); + let memo = egui::Id::new("titlebar-press-on-widget"); + if resp.is_pointer_button_down_on() && ctx.input(|i| i.pointer.primary_pressed()) { + let hovered = ctx.interaction_snapshot(|s| s.hovered.clone()); + let on_widget = + hovered.iter().any(|id| *id != handle && ctx.read_response(*id).is_some_and(|r| r.sense.senses_click() || r.sense.senses_drag())); + ctx.data_mut(|d| d.insert_temp(memo, on_widget)); + } + let on_widget = ctx.data(|d| d.get_temp::(memo)).unwrap_or(false); let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) { Some(Gesture::DoubleClicked) - } else if resp.drag_started_by(egui::PointerButton::Primary) { + } else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget { Some(Gesture::DragStarted) } else { None diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs index f1ff782e..40eba4b1 100644 --- a/crates/ui-egui/src/tests_titlebar.rs +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -46,7 +46,7 @@ fn double_click_on_the_empty_bar_zooms_then_restores() { play(&mut h); assert_eq!(h.window_commands, vec![ViewportCommand::Maximized(true)]); // a pause longer than egui's multi-click window, or the second pair would count as a triple click - for _ in 0..120 { + for _ in 0..30 { h.step(); } h.request("ui.click", json!({"x": p.x, "y": p.y, "count": 2}), T); @@ -84,6 +84,16 @@ fn double_click_on_a_button_does_not_zoom() { assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); } +// Scenario: pressing a button and dragging off it does not move the window (as in a native title bar) +#[test] +fn dragging_from_a_button_does_not_move_the_window() { + let mut h = integrated(); + let r = widget(&h, "icon:help"); + h.request("ui.drag", json!({"x": r.center().x, "y": r.center().y, "toX": r.center().x - 150.0, "toY": r.center().y + 30.0}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + // Scenario: double-clicking the search field selects a word, it does not zoom the window #[test] fn double_click_in_the_search_field_does_not_zoom() { From 2bd13c150068e5d55dd4ee2634a05fea831e8e1e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:42:52 -0400 Subject: [PATCH 7/9] Parity: note the stale zoomed-state limitation of the title-bar toggle Co-Authored-By: Claude Sonnet 5.5 --- docs/parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index b1932654..9e797eae 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -522,7 +522,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | -| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). Not checked on a real macOS window yet | +| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Not checked on a real macOS window yet | ## Y. Menus From eaaf85985334e464a11402d4886c2d594ffc17e6 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:44:23 -0400 Subject: [PATCH 8/9] Parity: title-bar drag and zoom confirmed on a real macOS window Co-Authored-By: Claude Sonnet 5.5 --- docs/parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index 9e797eae..6857e7a0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -522,7 +522,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | -| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Not checked on a real macOS window yet | +| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | ## Y. Menus From fec379db469a0fe8c3e295b73db56a9ea5434e37 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 23:47:41 +0000 Subject: [PATCH 9/9] Russian for two strings the catalog lacked (colour label cleared, slideshow hint) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01912WaK6Rmew3wxZuPyX7E8 --- crates/ui-egui/locales/ru.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 95fcfc81..365a4bf2 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -203,6 +203,7 @@ "Color Noise Reduction": "Подавление цветового шума", "Color Priority": "Приоритет цвета", "Color Range": "Цветовой диапазон", + "Color label cleared": "Цветовая метка снята", "Color space": "Цветовое пространство", "Color space: sRGB (AVIF)": "Цветовое пространство: sRGB (AVIF)", "Combine files…": "Объединить файлы…", @@ -1118,6 +1119,7 @@ "Skip": "Пропустить", "Sky": "Небо", "Slideshow": "Слайд-шоу", + "Slideshow · Space pauses · Esc ends": "Слайд-шоу · Пробел — пауза · Esc — выход", "Smart Album": "Смарт-альбом", "Smart Album Rule Fields": "Поля условий смарт-альбома", "Smart Preview Status": "Состояние смарт-просмотров",