From bf49935fe766d8bc5d72f2931eacb9b5977c93e2 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:57:37 +0200 Subject: [PATCH] LR-BEHAV-DIALOGS: dialogs fit the window; options scroll, title and buttons stay visible (#781) Every dialog but Import draws its options in a vertical scroll area capped at the window height minus the title bar, frame and button row (measured on the previous frame), so on a 1366x768 screen the Export dialog's title and Cancel / OK stay on screen. Dialogs that fit keep their size. Lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first rather than scrolling inside a scrolling dialog. Import keeps its resizable window. --- crates/ui-egui/src/credits.rs | 6 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/dialogs.rs | 84 +++++++++++++++- crates/ui-egui/src/panels/keymap.rs | 4 +- crates/ui-egui/src/tests_dialog_fit.rs | 133 +++++++++++++++++++++++++ docs/parity.md | 13 +-- docs/whats-new.md | 6 ++ 7 files changed, 234 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/tests_dialog_fit.rs diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 62ac811c7..964396850 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -266,7 +266,8 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); ui.label(RichText::new(crate::i18n::tr_format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().color(t.text_dim)); ui.separator(); - egui::ScrollArea::both().id_salt("credits_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { + let list_h = crate::panels::dialogs::list_height(ui, LIST_HEIGHT); + egui::ScrollArea::both().id_salt("credits_scroll").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| { if list.is_empty() { ui.label(crate::i18n::tr("No contributor data was built into this copy.")); } else if v.table { @@ -326,7 +327,8 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { + let list_h = crate::panels::dialogs::list_height(ui, LIST_HEIGHT); + egui::ScrollArea::both().id_salt("credits_models_scroll").max_height(list_h).auto_shrink([false, true]).show(ui, |ui| { egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { ui.label(RichText::new(crate::i18n::tr(h)).strong()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..6ee6e3f2e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -47,6 +47,8 @@ mod tests_curve; #[cfg(test)] mod tests_date_picker; #[cfg(test)] +mod tests_dialog_fit; +#[cfg(test)] mod tests_filmstrip; #[cfg(test)] mod tests_grid; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..c26c06863 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -128,6 +128,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); let window_id = egui::Id::new(if import { "lightcraft-import-dialog" } else { "lightcraft-dialog" }); + // Every other dialog fits the window (#781): its options scroll when they don't, and the title + // bar and the button row stay outside the scroll area. The room they take is measured on the + // previous frame (a guess on the first one). + let chrome_id = window_id.with("chrome"); + let chrome = ctx.data(|d| d.get_temp::(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); + let body_max = (screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome).max(DIALOG_MIN_BODY); + let mut body_h = None; let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) .resizable(import) @@ -144,7 +151,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }) .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; - match &mut dlg { + body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); ui.add( @@ -173,7 +180,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr("Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.")).color(t.text_dim)); } Dialog::WhatsNew => { - egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(list_height(ui, 460.0)).auto_shrink([false, true]).show(ui, |ui| { for line in WHATS_NEW.lines() { let l = line.trim_end(); if let Some(h) = l.strip_prefix("### ") { @@ -233,7 +240,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if !xmp.is_empty() { groups.push(("XMP".into(), xmp)); } - egui::ScrollArea::vertical().max_height(440.0).auto_shrink([false, true]).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(list_height(ui, 440.0)).auto_shrink([false, true]).show(ui, |ui| { if groups.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr(rows["note"].as_str().unwrap_or("No metadata found"))).color(t.text_dim)); } @@ -260,7 +267,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let now = (app.session.clock)(); let view = app.caches.rules_view(&app.session.catalog, rules, *id, folder, &now); let env = crate::panels::rules_editor::Env { problems: view.problems.clone(), today: now, albums: view.albums.clone() }; - egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show(ui, |ui| { + egui::ScrollArea::vertical().max_height(list_height(ui, 360.0)).auto_shrink([false, true]).show(ui, |ui| { crate::panels::rules_editor::edit(ui, rules, "rules", 0, &[], &env); }); let n = view.count; @@ -945,7 +952,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), - } + }); ui.add_space(4.0); ui.horizontal(|ui| { // a model file LightCraft cannot use has nothing to confirm @@ -1022,6 +1029,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } }); if let Some(w) = shown { + if let Some(body_h) = body_h { + let measured = (w.response.rect.height() - body_h).max(0.0); + if (measured - chrome).abs() > 0.5 { + ctx.data_mut(|d| d.insert_temp(chrome_id, measured)); + ctx.request_repaint(); + } + } ctx.move_to_top(w.response.layer_id); crate::widgets::register(ctx, "dialog:window", w.response.rect); } @@ -1071,6 +1085,66 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { app.ui.dialog = if close { None } else { Some(dlg) }; } +/// Room kept free above and below a dialog (#781). +const DIALOG_SCREEN_MARGIN: f32 = 12.0; +/// A dialog's title bar, frame and button row before the first frame measures them. +const DIALOG_CHROME_GUESS: f32 = 100.0; +/// A dialog's options never get less room than this, however short the window. +const DIALOG_MIN_BODY: f32 = 80.0; + +/// A list that scrolls on its own never gets shorter than this to make its dialog fit. +const DIALOG_MIN_LIST: f32 = 120.0; + +fn list_shrink_id() -> egui::Id { + egui::Id::new("dialog-list-shrink") +} + +fn list_applied_id() -> egui::Id { + egui::Id::new("dialog-list-applied") +} + +/// The height for a list in a dialog that scrolls on its own (What's New, All Metadata, the +/// shortcuts, smart-album rules): `preferred`, less what the dialog needs to fit the window, so +/// on a short window the list gets shorter rather than scrolling inside a scrolling dialog. +pub fn list_height(ui: &egui::Ui, preferred: f32) -> f32 { + let shrink = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); + let height = (preferred - shrink).max(preferred.min(DIALOG_MIN_LIST)); + ui.data_mut(|d| *d.get_temp_mut_or_default::(list_applied_id()) += preferred - height); + height +} + +/// A dialog's options: in a vertical scroll area at most `max_height` tall when `scroll` names +/// the dialog (it shrinks to its content, so a dialog that fits looks as it would without it; +/// each dialog keeps a scroll position of its own), drawn directly otherwise. Returns the height +/// they took when scrolled. +fn dialog_body(ui: &mut egui::Ui, scroll: Option<&str>, max_height: f32, body: impl FnOnce(&mut egui::Ui)) -> Option { + let Some(name) = scroll else { + ui.data_mut(|d| d.remove_temp::(list_shrink_id())); + body(ui); + return None; + }; + ui.data_mut(|d| d.insert_temp(list_applied_id(), 0.0_f32)); + let top = ui.cursor().top(); + // (an auto-sized window offers its content last frame's height; the minimum lets a dialog + // that grows take its full height at once, and the area still shrinks to fit its content) + let out = egui::ScrollArea::vertical() + .id_salt(("dialog-body", name)) + .max_height(max_height) + .min_scrolled_height(max_height) + .auto_shrink([true, true]) + .show(ui, body); + // what the lists that scroll on their own gave up this frame, and what they should next frame + let applied = ui.data(|d| d.get_temp::(list_applied_id())).unwrap_or(0.0); + let was = ui.data(|d| d.get_temp::(list_shrink_id())).unwrap_or(0.0); + let shrink = (out.content_size.y + applied - max_height).max(0.0); + if (shrink - was).abs() > 0.5 { + ui.data_mut(|d| d.insert_temp(list_shrink_id(), shrink)); + ui.ctx().request_repaint(); + } + // (the room the scroll area took: its `inner_rect` can be taller than what it allocated) + Some(ui.min_rect().bottom() - top) +} + /// Apply a dialog's action (also used by `ui.dialog.confirm`). /// `90` → `1 min 30 s`. pub fn fmt_gap(v: f64) -> String { diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index eef65ef89..a844b7b24 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -58,7 +58,9 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { rows.sort_by_cached_key(|(b, _)| crate::i18n::tr(b.label).to_lowercase()); // a fixed height, so the dialog doesn't jump around while the search narrows the list - egui::ScrollArea::vertical().max_height(400.0).min_scrolled_height(400.0).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| { + // (shorter on a short window, so the dialog fits it) + let h = crate::panels::dialogs::list_height(ui, 400.0); + egui::ScrollArea::vertical().max_height(h).min_scrolled_height(h).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| { egui::Grid::new("shortcuts").striped(true).num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| { for (b, sc) in &rows { row(app, ui, t, b, sc.as_deref(), mac); diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs new file mode 100644 index 000000000..d2d69c056 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -0,0 +1,133 @@ +//! Issue #781: a dialog taller than the window keeps its title and buttons on screen; its options +//! scroll. On a 1366×768 screen the Export dialog ran off both ends and couldn't be confirmed. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo(size: [f32; 2]) -> Headless { + let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, size, 1.0); + // let the first renders finish (a render still running at exit can crash in GPU teardown) + h.settle(T); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn open(h: &mut Headless, command: &str) { + let r = h.request("engine.execute", json!({"command": command, "params": {}}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + assert!(h.app.ui.dialog.is_some(), "{command} opens a dialog"); + h.settle(T); +} + +/// The dialog lies inside the window, and so do its buttons (`button:dialogCancel` when it has one). +fn assert_fits(h: &Headless, what: &str) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let dialog = rect(h, "dialog:window"); + assert!(screen.contains_rect(dialog), "{what}: dialog {dialog:?} runs off the {screen:?} window"); + let mut buttons = vec!["button:dialogOk"]; + if h.app.widgets.iter().any(|(w, _)| w == "button:dialogCancel") { + buttons.push("button:dialogCancel"); + } + for b in buttons { + let r = rect(h, b); + assert!(dialog.contains_rect(r), "{what}: {b} {r:?} is outside the dialog {dialog:?}"); + } +} + +/// The Export dialog in a window of `size`, set to write small JPEGs to a made-up folder. +fn export_dialog(size: [f32; 2]) -> Headless { + let mut h = demo(size); + open(&mut h, "dialog.export"); + if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog { + *full_size = false; + *resize = lightcraft_engine::export::Resize::long_edge(64); + *dir = "/lc-test-out".into(); + } + h.settle(T); + h +} + +// Scenario: on a 1366×768 screen (and a shorter window) the Export dialog's buttons are on screen and work +#[test] +fn export_dialog_fits_a_short_window_and_its_button_exports() { + for size in [[1366.0, 690.0], [1366.0, 520.0]] { + let mut h = export_dialog(size); + assert_fits(&h, &format!("Export at {size:?}")); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_none(), "Export closed the dialog at {size:?}"); + assert!(h.app.export.is_some() || h.app.last_export_result.is_some(), "Export started at {size:?}"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none())); + } +} + +// Scenario: Cancel is on screen too, and closes the dialog +#[test] +fn export_dialog_cancel_is_on_screen_in_a_short_window() { + let mut h = export_dialog([1366.0, 690.0]); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + assert!(screen.contains_rect(rect(&h, "button:dialogCancel"))); + h.request("ui.clickWidget", json!({"id": "button:dialogCancel"}), T); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_none()); + assert!(h.app.export.is_none() && h.app.last_export_result.is_none(), "nothing exported"); +} + +// Scenario: a window with room for it shows the Export dialog as before (no scrolling, same size) +#[test] +fn export_dialog_is_unchanged_in_a_tall_window() { + let h = export_dialog([1600.0, 1000.0]); + assert_fits(&h, "Export at 1600×1000"); + let dialog = rect(&h, "dialog:window"); + // (846 × 500.6 before #781: the whole dialog, nothing scrolled away) + assert!((dialog.height() - 846.0).abs() <= 1.0, "{dialog:?}"); + assert!((dialog.width() - 500.6).abs() <= 1.0, "{dialog:?}"); +} + +// Scenario: other long dialogs fit a short window too, with their buttons on screen +#[test] +fn long_dialogs_fit_a_short_window() { + let mut h = demo([1000.0, 320.0]); + for command in [ + "dialog.copySettings", + "dialog.pasteSettings", + "dialog.createPreset", + "dialog.contactSheet", + "app.settings", + "app.shortcuts", + "app.whatsNew", + "app.about", + "app.systemInfo", + "dialog.smartAlbum", + ] { + open(&mut h, command); + assert_fits(&h, command); + h.app.ui.dialog = None; + h.settle(T); + } +} + +// Scenario: a list that scrolls on its own (the shortcuts) gets shorter instead of scrolling inside a scrolling dialog +#[test] +fn a_dialog_list_shrinks_to_fit_a_short_window() { + let mut h = demo([1000.0, 320.0]); + open(&mut h, "app.shortcuts"); + let shrink = h.view.ctx.data(|d| d.get_temp::(egui::Id::new("dialog-list-shrink"))).unwrap_or(0.0); + assert!(shrink > 0.0, "the list gave up room"); + let dialog = rect(&h, "dialog:window"); + assert!(dialog.height() <= 320.0, "{dialog:?}"); +} diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..a03861841 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) | +| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | +| X. Cross-cutting behaviours (BEHAV) | 19 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 82 | 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** | 393 | 39 | 84 | 36 | 192/200 (96%) | 142/152 (93%) | +| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) | +| **Total** | 398 | 49 | 73 | 36 | 192/200 (96%) | 144/156 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.0% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 520 in-scope rows — P0 98.0% of 200 · P1 95.2% of 156 · P2 47.2% of 163. ## Top gaps @@ -447,7 +447,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only | +| LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | | LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | @@ -525,6 +525,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | +| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/panels/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests_dialog_fit.rs` | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen; lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window | | LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`sidebar_section_header`), `crates/ui-egui/src/state.rs` (`collapsed_sidebar`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date or Keywords header (or drive `ui.clickWidget` `sidebarSection:`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`collapsedSidebar` in the UI state). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | | LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..88ba2db5c 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## October 2026 +### Dialogs fit the window +- A dialog taller than the window no longer runs off it (issue #781): on a 1366×768 screen the Export dialog's title + and its Cancel / OK buttons were off screen, so it couldn't be confirmed. Now its options scroll and the title and + buttons stay visible. Long lists in dialogs (What's New, All Metadata, Keyboard Shortcuts) get shorter to fit. + Dialogs that fit look as before. + ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,