diff --git a/apps/lightcraft-web/src/bench.rs b/apps/lightcraft-web/src/bench.rs index 6b58c5106..484c0041f 100644 --- a/apps/lightcraft-web/src/bench.rs +++ b/apps/lightcraft-web/src/bench.rs @@ -54,6 +54,8 @@ impl Bench { } } + /// The render key of the main preview texture, or `None` while that slot holds no texture. + /// A changed key means the benchmark's requested main-image render has arrived. fn main_key(app: &LightcraftApp) -> Option { app.renderer.textures.get(&Slot::Main).map(|t| t.key) } diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index bd6426e53..fac72ff73 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -168,6 +168,9 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { Ok(()) } +/// Build the browser host's `Services`: library backup/restore through browser storage and a +/// download, asynchronous file picking, downloads for writes, and PNG encoding. Desktop-only +/// services (native pickers, reveal, model folders) stay unset. fn services(originals: Originals, backend: Option, files: Files, frozen: Rc>, ctx: egui::Context) -> Services { let (backup_backend, backup_files) = (backend.clone(), files); let restore_backend = backend.clone(); @@ -420,6 +423,9 @@ struct WebApp { } impl WebApp { + /// Construct the web app from the boot options: install originals and open the library from + /// browser storage (a read failure starts an empty session and a blocking `LibraryProblem`), + /// load the stored UI state, start render workers and the optional benchmark. fn new(cc: &eframe::CreationContext<'_>, boot: Boot, chinese_font: Vec) -> Self { let Boot { opts, backend, files, index, lib_dir, load_error } = boot; let originals = Originals::default(); @@ -684,6 +690,8 @@ impl WebApp { } impl eframe::App for WebApp { + /// eframe's non-drawing pass for the browser host: drain safety notices and queued inbox work, + /// load originals the UI requested, apply the cache budget, run the app's logic, then save. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { for text in safety::take_notices() { let now = self.app.tick_now(); @@ -700,10 +708,14 @@ impl eframe::App for WebApp { self.save(); } + /// Pass the browser's raw input to the app hook before egui processes it, so prompt barriers + /// and synthetic or dropped input are applied consistently with the desktop host. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { self.app.raw_input_hook(ctx, raw); } + /// eframe's UI pass: import queued drops unless a prompt blocks input, draw the app, update the + /// page title, and advance the benchmark once per root pass. fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { // Drops are the UI phase's: a hidden `logic`/`run_logic` tick must not import them twice. // A prompt owns the library too: keep taking the queue only after it is gone, so a drop diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index f68018465..b666a0cdc 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -75,6 +75,8 @@ impl eframe::App for App { self.0.logic(ctx); self.1.tick(&mut self.0, ctx); } + /// Forwards the host's raw-input pass to the app's hook before egui processes it, keeping + /// modal/prompt input barriers and queued synthetic or dropped input ahead of the panels. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(ctx, raw); } @@ -404,6 +406,9 @@ impl PrefsWriter { } } + /// Per-frame host tick: records the window's placement while visible, asks for a repaint when + /// a remembered size is due, and saves settings/library at most every three seconds or + /// immediately after the library path changes; a failed save is reported once and retried. fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { // (asked for before the input is read: the context is not asked from inside its own lock) let zoom = ctx.zoom_factor(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d12edebab..caacbc590 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -170,6 +170,9 @@ fn modifiers(p: &Value) -> egui::Modifiers { egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") } } +/// Parse a control-protocol key name into an egui key. `egui::Key::from_name` is tried first; +/// then lowercase aliases cover `return`/`enter`, `esc`/`escape`, `delete`/`backspace` (both +/// Backspace), arrow names, space/tab, and backslash, slash and square brackets. fn key_from(name: &str) -> Option { egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() { "enter" | "return" => Some(egui::Key::Enter), @@ -226,6 +229,8 @@ pub(crate) struct GestureOutcome { } impl GestureOutcome { + /// A gesture outcome that injected no widget-targeted events, so delivery has no geometry to + /// re-check. fn done(result: Value) -> Self { Self { result, target: None } } @@ -241,6 +246,8 @@ fn push_drag(app: &mut LightcraftApp, a: egui::Pos2, b: egui::Pos2, steps: u64, app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); } +/// The rectangle registered for automation id `id` in the latest completed pass. The last +/// registration wins, so a redrawn widget's current geometry is returned. pub(crate) fn widget_rect(app: &LightcraftApp, id: &str) -> Option { app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) } @@ -311,6 +318,9 @@ pub(crate) fn widget_target_visible(ctx: &egui::Context, hit: Option Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); @@ -774,6 +784,10 @@ pub(crate) fn gesture_events(app: &mut LightcraftApp, ctx: &egui::Context, metho } } +/// Encode `image` as PNG and write it to the capture's pinned target, returning the control +/// protocol's JSON result. The destination is revalidated before encoding; a new path is written +/// with create-new semantics (never replacing a file that appeared), and a missing encoder or +/// writer is an error. `None` only reports the image size without writing. pub fn save_screenshot(app: &mut LightcraftApp, image: &egui::ColorImage, target: Option<&lightcraft_engine::originals::WriteTarget>) -> Value { let [w, h] = image.size; let Some(target) = target else { diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index 01b1aefbd..a8ef56f39 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -397,6 +397,10 @@ fn command_impl(app: &mut crate::LightcraftApp, reset: bool, params: &Value, ren Ok(json!({"docking": app.ui.docking})) } +/// Draw the docked workspace: restore or fall back from an invalid layout, sync panel +/// visibility, render the panels and the current view in the canvas, then apply the docking +/// actions. If the layout changed during the pass, any in-flight dock drag is cancelled and the +/// docking actions it produced are not applied. pub fn show(app: &mut crate::LightcraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let mut workspace = app.ui.docking.clone().unwrap_or_else(|| from_ui(app)); diff --git a/crates/ui-egui/src/frame_rate.rs b/crates/ui-egui/src/frame_rate.rs index 69424e958..b213963af 100644 --- a/crates/ui-egui/src/frame_rate.rs +++ b/crates/ui-egui/src/frame_rate.rs @@ -43,6 +43,7 @@ struct Hud { logged: Option, } +/// Stable egui data id under which the FPS HUD's per-frame state is stored. fn hud_id() -> egui::Id { egui::Id::unique("fps-hud-state") } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e47165557..c47bd2773 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -35,6 +35,10 @@ pub fn viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; native_viewport_pixels(size, pixels_per_point) } +/// Turn a logical viewport size into native pixels for the headless renderer. Both edges and +/// the scale must be finite and positive; each scaled edge is rounded like egui's scissor and +/// must be within `1..=MAX_IMAGE_EDGE`, and the product must fit both the raster and screenshot +/// pixel budgets. Returns a user-facing error instead of clamping. fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> { let [width, height] = size; if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 2071d3e01..74dcc6c40 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -1397,6 +1397,10 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { }); } +/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail, +/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an +/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail +/// uses its width as height. fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) { let t = Tokens::get(ui.ctx()); let c = d.candidates[i].clone(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ca598b5f4..1b5fea8f1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -596,6 +596,8 @@ pub struct LightcraftApp { } impl LightcraftApp { + /// Build the app with default UI, renderer, cache and control state, and move AI segmentation + /// to a worker so frames apply results instead of waiting. pub fn new(mut session: Session, services: Services) -> Self { // AI mask requests run on the model's worker; frames apply their results (never wait) session.segmenter.background = true; @@ -710,6 +712,8 @@ impl LightcraftApp { self } + /// Attach the control-channel receiver and keep CPU copies of photo textures, which headless + /// screenshots need; returns the app for chaining. pub fn with_control(mut self, rx: Receiver) -> Self { self.control_rx = Some(rx); // keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them @@ -723,6 +727,7 @@ impl LightcraftApp { self.clock.now() } + /// The app clock in milliseconds, for control deadlines and timers. fn tick_now_ms(&self) -> f64 { self.clock.now() * 1000.0 } @@ -1181,6 +1186,9 @@ impl LightcraftApp { self.ui.toast = Some((text.into(), t + 6.0, None)); } + /// Take the control receiver for this frame, run every queued request whose sender still + /// wants it, and reply directly or admit its screenshot/gesture to a later completed pass. + /// Requests abandoned by their sender are skipped rather than executed late. fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -2868,10 +2876,12 @@ struct AppClock { } impl AppClock { + /// Start the clock at the current monotonic instant with no simulated time. fn new() -> Self { Self { start: web_time::Instant::now(), simulated: None } } + /// Seconds on the clock: the supplied simulated time when set, otherwise time since `new`. fn now(&self) -> f64 { self.simulated.unwrap_or_else(|| self.start.elapsed().as_secs_f64()) } diff --git a/crates/ui-egui/src/menu_level.rs b/crates/ui-egui/src/menu_level.rs index e888e9ca4..611812901 100644 --- a/crates/ui-egui/src/menu_level.rs +++ b/crates/ui-egui/src/menu_level.rs @@ -15,6 +15,7 @@ const EDGE: f32 = 6.0; /// How fast resting on a scroll arrow scrolls, in points per second. const SPEED: f32 = 600.0; +/// The egui temp-data id holding the row a menu at `depth` hangs from. fn anchor_id(depth: usize) -> Id { Id::unique(("lc-menu-anchor", depth)) } diff --git a/crates/ui-egui/src/model_setup.rs b/crates/ui-egui/src/model_setup.rs index 9042a9502..78587697e 100644 --- a/crates/ui-egui/src/model_setup.rs +++ b/crates/ui-egui/src/model_setup.rs @@ -91,6 +91,8 @@ pub(crate) fn intercept(app: &mut LightcraftApp, command: &str, params: &Value) })) } +/// Show the pending model-setup note for `kind` in the model dialog, with a button that cancels +/// the pending action. Does nothing unless a valid denoise setup is waiting. pub(crate) fn notice(app: &mut LightcraftApp, ui: &mut egui::Ui, kind: &str) { if kind != "denoise" || !app.model_setup.denoise.as_ref().is_some_and(|r| r.valid(app)) { return; @@ -118,6 +120,11 @@ fn abandoned(app: &mut LightcraftApp) -> bool { !v["downloads"].as_array().into_iter().flatten().any(|d| matches!(d["state"].as_str(), Some("running" | "done" | "installing" | "installed"))) } +/// Advance a pending model setup. An abandoned setup is cancelled on any call (the +/// abandonment/download probe is not throttled); while another interaction is open it waits, and +/// otherwise it checks the photo's model state at most every 250 ms. A photo that still has no +/// model (or cannot use one) keeps waiting; otherwise denoise is enabled and the outcome reported +/// in the status line. pub(crate) fn pump(app: &mut LightcraftApp, ctx: &egui::Context, now: f64) { if app.model_setup.denoise.is_none() { return; diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index 575fa008d..fdb208b51 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -608,6 +608,9 @@ fn count(n: u64, one: &str, many: &str) -> String { tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { one } else { many })) } +/// The pace to ask the denoise worker for: "light" when minimized, "normal" while unfocused, +/// "pause" shortly after input, then "light"/"full"/"normal" according to move recency and +/// whether the Denoise settings tab is being watched. pub(super) fn window_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64, watching: bool) -> &'static str { let (focused, minimized) = ctx.input(|i| (i.raw.focused, i.viewport().minimized.unwrap_or(false))); pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching) @@ -665,6 +668,9 @@ pub(super) fn open_page(app: &mut LightcraftApp, url: &str) { } } +/// Draw the denoise model's terms dialog from the engine's model info: unsupported files show +/// why; otherwise the name/size, download host, licence and provenance, an optional page link, +/// and the acceptance checkbox in `accepted`. pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info: &Value, accepted: &mut bool) { let kind = info["kind"].as_str().unwrap_or("unsupported"); let file = info["fileName"].as_str().unwrap_or(""); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 05bd7a511..5e610b098 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -498,6 +498,10 @@ fn draw_window(p: &egui::Painter, app: &LightcraftApp, c: &WindowCtx, v: &Window p.image(tex.tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } +/// Draw the Develop loupe: the optional filmstrip, the fitted or zoomed photo (before/after and +/// full-screen aware), crop overlays and the render requests for the current view, or, when no +/// photo is active, the empty-library message if the library is empty and "No photo selected" +/// otherwise. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let full = ui.max_rect(); @@ -2912,6 +2916,11 @@ fn film_label(name: &str) -> String { if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::()) } else { name.to_string() } } +/// Draw the horizontal filmstrip in `r`: selectable thumbnail cells with names and format when +/// film names are on, badges when film badges are on, and stack counts, following the active +/// photo and scrolling sideways with the wheel. Clicks select: Command toggles and Shift extends +/// the range; in Compare they adjust the pair's selection, and a plain click replaces the active +/// side. pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(r, 0.0, t.canvas); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 783109ed9..f26b43325 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -341,8 +341,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { }) .show(ctx, |ui| { // Disabled, not skipped: the dialog keeps its layout and state, but a raw event in the - // first prompt frame cannot toggle a checkbox or press a button underneath it. - if blocked { + // first prompt frame cannot toggle a checkbox or press a button underneath it. The + // blocking library window gets the same treatment: it owns input while it is on screen, + // so the dialog behind it must not take focus or accept typed text either. + if raw_blocked { ui.disable(); } ui.spacing_mut().item_spacing.y = 8.0; @@ -1172,7 +1174,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { .width(f32::INFINITY) .select_on_focus(true) .show(ui); - if just_opened(ui, "keyword-tag") { + if ui.is_enabled() && just_opened(ui, "keyword-tag") { r.response.request_focus(); } if r.ending == Some(crate::text_field::Ending::Return) { @@ -1217,7 +1219,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) { .width(f32::INFINITY) .select_on_focus(true) .show(ui); - if just_opened(ui, "keyword-set") { + if ui.is_enabled() && just_opened(ui, "keyword-set") { r.response.request_focus(); } let mut returned = r.ending == Some(crate::text_field::Ending::Return); @@ -2440,8 +2442,10 @@ fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) - fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) -> bool { let r = crate::text_field::TextField::singleline(widget, text).hint(crate::i18n::tr(hint)).width(f32::INFINITY).select_on_focus(true).show(ui); // (and takes it back when nothing has it: after a click on the dialog's background or a check box, - // or when Return gave it up on a refusal; a control Tab reached keeps it) - if just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none())) { + // or when Return gave it up on a refusal; a control Tab reached keeps it). A disabled dialog (a + // prompt or the blocking library window owns input) must not request the focus at all: egui's + // text edit would otherwise keep editing the draft from host text behind that window. + if ui.is_enabled() && (just_opened(ui, widget) || (!r.editing && r.ending.is_none() && ui.memory(|m| m.focused().is_none()))) { r.response.request_focus(); } r.ending == Some(crate::text_field::Ending::Return) diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index 0d4ee5ef9..3ebe25057 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -15,6 +15,9 @@ use crate::widgets::register; const SEARCH: &str = "shortcuts-search"; const SHOW_ALL: &str = "shortcuts-show-all"; +/// Draw the Keyboard Shortcuts editor: search and "show all", Reset All, one row per bindable +/// command that has a shortcut (all bindable commands when "Show commands without a shortcut" is +/// on), and the fixed non-command keys. Pending shortcut capture is processed once per frame. pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; capture(app, ui.ctx()); @@ -79,6 +82,9 @@ fn menu_text(sc: &str, mac: bool) -> String { crate::menubar::shortcut_text(sc, mac) } +/// One editable command row: its label and id, a click-to-record shortcut button, and buttons +/// that clear or restore it. The restore button is enabled when a saved binding exists or the +/// declared default is currently yielded to another command. fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) { // a declared key that yields to another command's saved key counts as changed: ↺ takes it back let changed = app.ui.settings.keymap.contains_key(b.id) || (sc.is_none() && b.default.is_some()); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 680c5bf3b..5a81edfab 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -639,6 +639,8 @@ fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } +/// Draw the My Photos sidebar: title and open-library name, the scrollable sources, albums, +/// folders and sections, with the section visibility menu on the title. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index 841dac140..31128e9a9 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -30,6 +30,7 @@ const DIM_ID: &str = "library-problem-dim"; const ERROR_WINDOW_ID: &str = "library-problem"; const CHOOSER_WINDOW_ID: &str = "library-chooser"; +/// The named `Foreground` layer used by the dim and the two blocking library windows. fn foreground_layer(id: &str) -> egui::LayerId { egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(id)) } @@ -110,6 +111,8 @@ impl WindowBarrier { } } +/// The blocking library window that is present (not dismissed), if any: the launch chooser when +/// it is shown, otherwise the error window. fn present_mode(app: &LightcraftApp) -> Option { let problem = app.library_problem.as_ref().filter(|p| !p.dismissed)?; Some(if problem.chooser.is_some() { WindowMode::Chooser } else { WindowMode::Error }) @@ -155,6 +158,8 @@ fn note_first_draw(app: &mut LightcraftApp, mode: WindowMode) { } } +/// Surrender egui's current keyboard focus, so a background field cannot receive text in the +/// window's first shielded pass. fn clear_focus(ctx: &egui::Context) { ctx.memory_mut(|memory| { if let Some(id) = memory.focused() { diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 65aefb225..4dbbc1fd9 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -129,6 +129,8 @@ struct PromptKey { queue_len: usize, } +/// Identity of the prompt currently owning input: its layer, text and, for the settings-notice +/// queue, the number of notices remaining, including the one shown. `None` when no prompt is up. fn prompt_key(app: &LightcraftApp) -> Option { let (layer, text, queue_len) = match (&app.quit_prompt, app.notices.first()) { (Some(prompt), _) => (prompt_layer(app)?, prompt.text().to_string(), 0), @@ -168,6 +170,8 @@ pub(crate) struct PromptInput { } impl PromptInput { + /// Reset the prompt input state for `key`, discarding layout, focus, button ids and pending + /// activation. A present key arms the shield transition; `None` leaves input unowned. fn reset_to(&mut self, key: Option) { let transition = key.is_some(); self.key = key; @@ -181,11 +185,13 @@ impl PromptInput { self.pointer = None; } + /// The index of the prompt button whose real egui id matches AccessKit's `node`, if any. fn button_index(&self, accesskit_node: egui::accesskit::NodeId) -> Option { self.button_ids.iter().position(|(_, id)| id.accesskit_id() == accesskit_node) } } +/// Surrender egui's current keyboard focus before a chosen prompt button disappears. fn clear_prompt_focus(ctx: &egui::Context) { ctx.memory_mut(|memory| { if let Some(id) = memory.focused() { @@ -194,6 +200,8 @@ fn clear_prompt_focus(ctx: &egui::Context) { }); } +/// Whether a key press belongs to the prompt: Tab (optionally with Shift) or unmodified Enter, +/// Space or Escape. fn prompt_key_allowed(key: egui::Key, modifiers: egui::Modifiers) -> bool { match key { egui::Key::Tab => modifiers.is_none() || modifiers.shift_only(), @@ -202,6 +210,8 @@ fn prompt_key_allowed(key: egui::Key, modifiers: egui::Modifiers) -> bool { } } +/// Whether an AccessKit action targets a prompt button on the root tree and is a Focus or Click +/// request. fn prompt_accesskit_allowed(request: &egui::accesskit::ActionRequest, ids: &[(String, egui::Id)]) -> bool { request.target_tree == egui::accesskit::TreeId::ROOT && ids.iter().any(|(_, id)| id.accesskit_id() == request.target_node) @@ -426,6 +436,9 @@ pub(crate) fn finish_ui_pass(app: &mut LightcraftApp, ctx: &egui::Context) { } } +/// Draw one modal prompt and return the index of the chosen button. The dim takes the modal +/// layer and a sizing pass is discarded so the first interactive pass has settled geometry; +/// `input` keeps focus on the safe Cancel/OK button unless a keyboard or AccessKit choice arrives. fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&str, &str, Choice)], input: &mut PromptInput) -> Option { let t = Tokens::get(ctx); let screen = ctx.content_rect(); @@ -505,6 +518,9 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s chosen } +/// Draw the prompt owning input, if any, and act on its answer: quit-while-tasks cancels jobs +/// and re-checks for unsaved changes; quit-with-unsaved retries, quits or cancels; a settings +/// notice is removed when acknowledged. Returns without drawing when no prompt is up. pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(QuitPrompt::Tasks(text)) = app.quit_prompt.clone() { let buttons = [("quitAnyway", "Quit Anyway", Choice::QuitAnyway), ("quitCancel", "Cancel", Choice::Cancel)]; diff --git a/crates/ui-egui/src/panels/second.rs b/crates/ui-egui/src/panels/second.rs index 49c616915..cbdaa2d73 100644 --- a/crates/ui-egui/src/panels/second.rs +++ b/crates/ui-egui/src/panels/second.rs @@ -6,6 +6,9 @@ use egui::{Color32, Rect, pos2, vec2}; use crate::LightcraftApp; use crate::render::Slot; +/// Show the optional second window: an immediate viewport titled "LightCraft — Second Window" +/// whose body draws the active photo fitted. Closing a real window turns the option off; +/// embedded web and headless hosts get a floating window instead. pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if !app.ui.second_window { return; @@ -22,6 +25,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); } +/// Draw the second window's content: register `view:secondWindow`, request a loupe render sized +/// for the window (each edge clamped to 64 through the texture-side limit) and paint the active +/// photo fitted, or "No photo selected". fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) { let area = ui.available_rect_before_wrap(); crate::widgets::register(ui, "view:secondWindow", area); diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index f1d6f3043..c1c645a1d 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -45,6 +45,9 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, bar: Rect, search_rect: } } +/// Draw the top bar: the titlebar drag area, the in-window menus when there is no native menu +/// bar, sidebar and back/forward buttons, the centred search field, the filter button with its +/// active count, and the right-side action icons. pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // room for the traffic lights; in full screen they are gone, and the sidebar button sits as far diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e7b96644e..8ea82f68d 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -363,6 +363,9 @@ fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { }) } +/// Dispatch the global shortcut pass. It yields while a dialog, prompt, blocking library window, +/// text field, name edit or shortcut recording owns input, then fires saved command bindings, +/// aliases and fixed photo keys, respecting native menu ownership and open popups. pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) { app.recording_shortcut = None; diff --git a/crates/ui-egui/src/tests_library_problem.rs b/crates/ui-egui/src/tests_library_problem.rs index de61b3653..dc7a037a4 100644 --- a/crates/ui-egui/src/tests_library_problem.rs +++ b/crates/ui-egui/src/tests_library_problem.rs @@ -730,3 +730,85 @@ fn clipboard_pastes_into_the_launch_lists_own_text_prompt() { }; assert!(value.contains("pasted-name"), "the paste reached the chooser's own text field: {value:?}"); } + +/// The blocking library window owns input while it is on screen, including over an unrelated +/// dialog it did not open. Disabling only the dialog widgets that handle pointer input is not +/// enough: `dialog_field` must not take the focus back, or egui's text edit keeps consuming host +/// text into the draft behind the window. Several settled barrier frames must stay inert, and the +/// same field must accept typing again once the window is gone. +#[test] +fn a_settled_library_window_keeps_a_background_dialogs_text_draft_unchanged() { + let mut h = Headless::new( + LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + h.app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: "Trip".into(), folder: false, parent: None, include_selected: false }); + for _ in 0..4 { + h.step(); + } + assert!(h.view.ctx.text_edit_focused(), "the dialog's field takes the focus while it is the top modal"); + h.app.library_problem = Some(LibraryProblem::new("/no/such/library", "a test error")); + assert!(h.settle(T), "the library window settles over the dialog"); + assert!(has(&h, "button:libraryRetry"), "the library window is laid out"); + assert!(crate::panels::library_problem::blocks_dialog_input(&h.app), "the library window owns input"); + assert!(!h.view.ctx.text_edit_focused(), "the background dialog's field must not regain the focus behind the library window"); + for text in ["leak", "ed", "-draft"] { + h.events.push(egui::Event::Text(text.into())); + h.step(); + let draft = match &h.app.ui.dialog { + Some(crate::state::Dialog::NewAlbum { name, .. }) => name.as_str(), + other => panic!("the background New Album dialog is still open: {other:?}"), + }; + assert_eq!(draft, "Trip", "host text on a settled library-barrier frame must not mutate the dialog draft"); + } + assert!(!h.view.ctx.text_edit_focused(), "several settled barrier frames still leave the field unfocused"); + // Positive control: with the library window gone, the same field takes focus and typing again. + h.app.library_problem = None; + assert!(h.settle(T), "the dialog settles once the library window is gone"); + assert!(!crate::panels::library_problem::blocks_dialog_input(&h.app), "there is no barrier now"); + assert!(h.view.ctx.text_edit_focused(), "the dialog's field is usable again"); + h.events.push(egui::Event::Text("Holiday".into())); + h.step(); + let draft = match &h.app.ui.dialog { + Some(crate::state::Dialog::NewAlbum { name, .. }) => name.as_str(), + other => panic!("the New Album dialog is still open: {other:?}"), + }; + assert_eq!(draft, "Holiday", "typing reaches the dialog once the library barrier is gone"); +} + +/// The launch list's own New Library name prompt is not a background dialog: `visible_mode` is +/// None while it is open, so the dialog must stay enabled and `dialog_field` must keep giving it +/// the focus and accept host text. +#[test] +fn typing_reaches_the_launch_lists_own_new_library_prompt() { + let settings = crate::state::AppSettings { + recent_libraries: vec!["/no/such/library-one".into()], + library_path: "/no/such/library-one".into(), + ..Default::default() + }; + let mut h = Headless::new( + LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { png: None, pick_folder: Some(Box::new(|| -> Option { None })), ..Default::default() }, + ), + [1200.0, 760.0], + 1.0, + ); + h.settle(T); + h.app.library_problem = Some(LibraryProblem::choose(&settings)); + h.settle(T); + assert!(h.app.run("app.newLibrary", json!({})).is_ok(), "New Library opens its name prompt"); + h.step(); + h.step(); + assert!(!crate::panels::library_problem::blocks_dialog_input(&h.app), "the launch list's own prompt is not a background barrier"); + assert!(h.view.ctx.text_edit_focused(), "the prompt's name field has the focus"); + h.events.push(egui::Event::Text("Fresh".into())); + h.step(); + let Some(crate::state::Dialog::TextPrompt { value, command, .. }) = &h.app.ui.dialog else { + panic!("the New Library name dialog closed unexpectedly"); + }; + assert_eq!(command, "app.newLibrary"); + assert_eq!(value, "Fresh", "typing reaches the launch list's own text prompt"); +} diff --git a/crates/ui-egui/src/text_field.rs b/crates/ui-egui/src/text_field.rs index a9a98d86b..f59be23dc 100644 --- a/crates/ui-egui/src/text_field.rs +++ b/crates/ui-egui/src/text_field.rs @@ -162,6 +162,12 @@ impl<'a> TextField<'a> { self } + /// Draw the field and return its response, any ending this frame, and whether an edit is + /// under way. Per-frame state keeps the pre-edit text for Esc, the selection across the + /// context menu, and select-on-focus; single-line Enter ends the edit, and focus loss ends it + /// except when the focus went to the field's own menu (a choice from the menu resumes the + /// edit). The menu offers cut/copy/paste/select-all (paste is omitted on the web; egui also + /// handles their keyboard shortcuts), and a field not drawn on the previous pass starts clean. pub fn show(self, ui: &mut Ui) -> TextFieldResponse { let id = id(self.widget); let mut memo: Memo = ui.data_mut(|d| d.get_temp(id.with("memo"))).unwrap_or_default(); @@ -191,6 +197,16 @@ impl<'a> TextField<'a> { Action::Refocus => {} } } + // A field focused by the previous pass (a dialog asks after drawing it) must already + // select its text on the pass that carries the first keystroke: egui processes text + // inside `ui.add`, before the after-add branch below could select, leaving the new text + // inserted into the old. A press may go on to drag a selection, so it keeps the + // after-add path; an edit already under way keeps its pre-edit text for Esc. + let focused_at_start = ui.memory(|m| m.has_focus(id)); + if focused_at_start && memo.before.is_none() && self.select_on_focus && !ui.input(|i| i.pointer.any_down()) { + memo.before = Some(self.text.clone()); + select_all(ui.ctx(), id, self.text); + } let name = self.label.clone().or_else(|| self.hint.clone()); let mut edit = if self.multiline { egui::TextEdit::multiline(self.text) } else { egui::TextEdit::singleline(self.text) }; edit = edit.id(id); @@ -433,6 +449,12 @@ mod tests { } fn frame(&mut self, events: Vec) { + self.frame_with(events, false); + } + + /// A frame that asks for the field's focus after drawing it, as a dialog does, so the + /// focus takes effect on the next frame. + fn frame_with(&mut self, events: Vec, focus_after: bool) { self.time += 1.0 / 60.0; let mut raw = HeadlessView::raw_input(egui::vec2(400.0, 300.0), 1.0, self.time, events); raw.focused = self.window_focused; @@ -445,6 +467,9 @@ mod tests { let r = TextField::singleline(FIELD, text).width(200.0).select_on_focus(select).show(ui); endings.extend(r.ending); *editing = r.editing; + if focus_after { + ui.memory_mut(|m| m.request_focus(id(FIELD))); + } }); self.widgets = crate::widgets::take_registry(&self.view.ctx); } @@ -699,6 +724,25 @@ mod tests { assert_eq!(rig.text, "Kyoto"); } + /// A field focused by the pass before (a dialog asks for focus after drawing it) selects its + /// text before egui handles the first keystroke of the pass the focus takes effect, so that + /// keystroke replaces the text instead of being inserted into it. + #[test] + fn text_typed_on_the_first_focused_pass_replaces_the_whole_text() { + let mut rig = Rig::new("LightCraft Library"); + rig.select_on_focus = true; + // the dialog draws the field, then asks for its focus: effective on the next pass + rig.frame_with(vec![], true); + assert!(rig.view.ctx.memory(|m| m.has_focus(id(FIELD))), "focused for the next pass"); + // the pass the focus takes effect already carries the text + rig.frame(vec![Event::Text("Fresh".into())]); + assert_eq!(rig.text, "Fresh", "the first keystroke replaced the selected text"); + // the text from before the edit is still what Esc gives back + rig.key(Key::Escape, Modifiers::NONE); + assert_eq!(rig.text, "LightCraft Library"); + assert_eq!(rig.endings, vec![Ending::Escape]); + } + /// Esc after using the menu still gives back the text from before the whole edit. #[test] fn escape_after_the_menu_gives_back_the_original_text() {