mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
Merge PR #786
# Conflicts: # crates/ui-egui/src/lib.rs # crates/ui-egui/src/panels/dialogs.rs
This commit is contained in:
7 files changed
+232
-12
No files matched your search
@@ -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());
|
||||
|
||||
@@ -59,6 +59,8 @@ mod tests_date_picker;
|
||||
#[cfg(test)]
|
||||
mod tests_dialogs;
|
||||
#[cfg(test)]
|
||||
mod tests_dialog_fit;
|
||||
#[cfg(test)]
|
||||
mod tests_filmstrip;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
|
||||
@@ -154,6 +154,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 = window_id(&dlg);
|
||||
// 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::<f32>(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)
|
||||
@@ -171,7 +178,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(
|
||||
@@ -200,7 +207,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("### ") {
|
||||
@@ -260,7 +267,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));
|
||||
}
|
||||
@@ -287,7 +294,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;
|
||||
@@ -994,7 +1001,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, nor has a dialog that
|
||||
@@ -1078,6 +1085,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
if let Some(w) = shown {
|
||||
ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id));
|
||||
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);
|
||||
}
|
||||
@@ -1127,6 +1141,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::<f32>(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::<f32>(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<f32> {
|
||||
let Some(name) = scroll else {
|
||||
ui.data_mut(|d| d.remove_temp::<f32>(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::<f32>(list_applied_id())).unwrap_or(0.0);
|
||||
let was = ui.data(|d| d.get_temp::<f32>(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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::<f32>(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:?}");
|
||||
}
|
||||
@@ -49,13 +49,13 @@ cited here still exists. Formerly docs/parity.md.
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| 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 | 33 | 15 | 35 | 9 | — | 23/25 (92%) |
|
||||
| **Total** | 397 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) |
|
||||
| **Total** | 398 | 49 | 73 | 36 | 192/200 (96%) | 144/156 (92%) |
|
||||
|
||||
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.2%** of 519 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 47.2% 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.
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps and shortcut notes
|
||||
@@ -381,7 +381,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
|
||||
| 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`) | |
|
||||
@@ -459,6 +459,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| 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:<albums\|local\|byDate\|keywords>`) 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`) | |
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
## 2026-10-10
|
||||
|
||||
### 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,
|
||||
|
||||
Reference in new issue
Block a user