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/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": "Состояние смарт-просмотров", 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/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), _ => {} } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1699d57c..8c723b9b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -20,12 +20,14 @@ pub mod menubar; pub mod menus; pub mod merge; pub mod panels; +pub mod pick; pub mod render; pub mod shortcuts; pub mod softpaint; pub mod state; pub mod tasks; pub mod theme; +pub mod titlebar; pub mod widgets; #[cfg(test)] @@ -57,6 +59,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}; @@ -91,6 +95,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`). @@ -174,6 +182,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. @@ -252,6 +262,7 @@ impl LightcraftApp { screenshot_token: 0, shadow: None, synthetic: vec![], + pending_picks: vec![], synthetic_mods: egui::Modifiers::NONE, synthetic_mods_release: false, styled: false, @@ -876,6 +887,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/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index e960909e..d06a7559 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -8,6 +8,40 @@ 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 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) && !on_widget { + 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 +50,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/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/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs new file mode 100644 index 00000000..40eba4b1 --- /dev/null +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -0,0 +1,117 @@ +//! 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..30 { + 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: 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() { + 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()); +} 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); + } +} 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 | diff --git a/docs/parity.md b/docs/parity.md index a503d4ae..94302e3e 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). 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