diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 63a4deb7..87c7f175 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -303,16 +303,16 @@ fn mcp(args: &[String]) -> Result<(), String> { while i < args.len() { match args[i].as_str() { "--connect" => { - // Optional address argument. - match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) { - Some(a) => { - connect = Some(a.clone()); - i += 1; - } - None => connect = Some(DEFAULT_ADDR.to_string()), + let (addr, took) = connect_value(args.get(i + 1).map(String::as_str)); + connect = Some(addr); + if took { + i += 1; } } - a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()), + a if a.starts_with("--connect=") => { + let (addr, _) = connect_value(Some(&a["--connect=".len()..])); + connect = Some(addr); + } "--headless" => connect = None, "--demo" => demo = true, "--compact" => compact = true, @@ -525,14 +525,17 @@ fn run(args: &[String]) -> Result<(), String> { i += 1; break; } - "--connect" => match args.get(i + 1).filter(|a| a.contains(':') && !a.contains('=')) { - Some(a) => { - connect = Some(a.clone()); + "--connect" => { + let (addr, took) = connect_value(args.get(i + 1).map(String::as_str)); + connect = Some(addr); + if took { i += 1; } - None => connect = Some(DEFAULT_ADDR.to_string()), - }, - a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()), + } + a if a.starts_with("--connect=") => { + let (addr, _) = connect_value(Some(&a["--connect=".len()..])); + connect = Some(addr); + } "--demo" => demo = true, "--library" => library = Some(take_value(args, &mut i, "--library")?.to_string()), "--import" => imports.push(take_value(args, &mut i, "--import")?.to_string()), @@ -627,6 +630,18 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s /// The port an `--connect` address names, for the recovery hint. The whole address when it has /// no port, so a hint is never built from a guess. +/// `--connect` takes an address, or a bare port (`7980` → `127.0.0.1:7980`). A following +/// command id is left alone (#1178). +fn connect_value(next: Option<&str>) -> (String, bool) { + match next { + Some(a) if !a.starts_with('-') && a.contains(':') && !a.contains('=') => (a.to_string(), true), + Some(a) if !a.is_empty() && a.bytes().all(|c| c.is_ascii_digit()) && a.parse::().ok().is_some_and(|p| p > 0) => { + (format!("127.0.0.1:{a}"), true) + } + _ => (DEFAULT_ADDR.to_string(), false), + } +} + fn connect_port(addr: &str) -> &str { addr.rsplit_once(':').map_or(addr, |(_, port)| port) } @@ -965,6 +980,20 @@ fn denoise_serve(args: &[String]) -> Result<(), String> { serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}"))) } +#[cfg(test)] +mod connect_tests { + use super::connect_value; + + #[test] + fn a_bare_port_is_loopback_and_a_command_is_not_consumed() { + assert_eq!(connect_value(Some("7980")), ("127.0.0.1:7980".into(), true)); + assert_eq!(connect_value(Some("127.0.0.1:7980")), ("127.0.0.1:7980".into(), true)); + assert_eq!(connect_value(Some("photo.rate")), ("127.0.0.1:7980".into(), false)); + assert_eq!(connect_value(Some("0")), ("127.0.0.1:7980".into(), false)); + assert_eq!(connect_value(None), ("127.0.0.1:7980".into(), false)); + } +} + #[cfg(not(feature = "denoise"))] fn denoise_serve(_: &[String]) -> Result<(), String> { Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into()) diff --git a/contributors/people.toml b/contributors/people.toml index 79f20c72..e153870f 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -27,3 +27,8 @@ sync_github_name = true [people.sanyamsingh001] display_name = "sanyamsingh001" + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index c96c5334..1ef6f790 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -245,11 +245,25 @@ pub fn decode_unguarded(bytes: &[u8], format: Format, opts: &DecodeOptions) -> R /// Convert a decode result to linear Rec.2020 D65 (Bradford-adapted), the develop working space. pub fn to_working(d: &Decoded) -> Rgb32f { - if d.space.is_working() { - return d.image.clone(); + let mut image = if d.space.is_working() { + d.image.clone() + } else { + let m = d.space.to_working().to_f32(); + d.image.map(|p| mat_apply(&m, p)) + }; + // Straight alpha onto white. JPEG and contact sheets have no alpha, and a transparent + // pixel is usually stored as black (#1212). + if let Some(alpha) = &d.alpha { + let w = image.width; + lightcraft_raster::par_rows(&mut image.data, w, |y, row| { + let a = alpha.row(y); + for (p, &alpha) in row.iter_mut().zip(a) { + let a = alpha.clamp(0.0, 1.0); + *p = p.map(|c| c * a + (1.0 - a)); + } + }); } - let m = d.space.to_working().to_f32(); - d.image.map(|p| mat_apply(&m, p)) + image } /// Where a thumbnail came from. @@ -323,3 +337,37 @@ pub fn decode_thumbnail_with(bytes: &[u8], opts: &ThumbnailOptions) -> Result Vec { out } +#[cfg(test)] +mod expand_tests { + use super::expand_paths; + + #[test] + fn a_folder_import_includes_heic_heif_and_nrw() { + let dir = std::env::temp_dir().join(format!("lightcraft-ext-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for name in ["a.heic", "b.HEIF", "c.nrw", "d.png", "note.txt"] { + std::fs::write(dir.join(name), b"x").unwrap(); + } + let got = expand_paths(&[dir.to_string_lossy().to_string()]); + let names: Vec = got.iter().map(|p| std::path::Path::new(p).file_name().unwrap().to_string_lossy().to_lowercase()).collect(); + assert!(names.contains(&"a.heic".into()), "{names:?}"); + assert!(names.contains(&"b.heif".into()), "{names:?}"); + assert!(names.contains(&"c.nrw".into()), "{names:?}"); + assert!(names.contains(&"d.png".into()), "{names:?}"); + assert!(!names.iter().any(|n| n == "note.txt"), "{names:?}"); + let _ = std::fs::remove_dir_all(&dir); + } +} + /// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless), /// `.avif`, `.jxl` — via the shared export encoder (embeds an sRGB profile). pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result, String> { diff --git a/crates/pipeline/src/spots.rs b/crates/pipeline/src/spots.rs index 483f83f3..3bb775df 100644 --- a/crates/pipeline/src/spots.rs +++ b/crates/pipeline/src/spots.rs @@ -247,8 +247,9 @@ fn fill_box(src: &mut [[f32; 3]], tgt: &[[f32; 3]], alpha: &[f32], bw: usize, bh let flat: Vec = tgt.iter().flatten().copied().collect(); let out = lightcraft_inpaint::complete(bw, bh, 3, &flat, &hole, &lightcraft_inpaint::CompleteParams::default()) .unwrap_or_else(|| lightcraft_inpaint::membrane_fill(bw, bh, 3, &flat, &hole)); - for (d, v) in src.iter_mut().zip(out.chunks_exact(3)) { - *d = [v[0], v[1], v[2]]; + let (pixels, _) = out.as_chunks::<3>(); + for (d, v) in src.iter_mut().zip(pixels) { + *d = *v; } } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 10cc7e0e..475f27e0 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -703,6 +703,7 @@ "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", "In use": "In Verwendung", "In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.", + "Include selected photos": "Ausgewählte Fotos einschließen", "Include subfolders": "Unterordner einbeziehen", "Increase Brush Feather": "Weiche Pinselkante vergrößern", "Increase Brush Size": "Pinselgröße vergrößern", diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b..9c1b6c01 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1678,7 +1678,7 @@ mod tests { } assert_eq!(h.app.ui.dialog, None, "Return confirmed the dialog ({typed})"); }; - confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None }, "Weddings"); + confirm(&mut h, Dialog::NewAlbum { name: String::new(), folder: false, parent: None, include_selected: true }, "Weddings"); assert!(album(&h, "Weddings").is_some(), "New Album"); confirm(&mut h, Dialog::RenameAlbum { id: garden, name: "Garden".into() }, "Flowers"); assert_eq!(album(&h, "Flowers"), Some(garden), "Rename Album"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77..b2b60f18 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -114,6 +114,8 @@ mod tests_sync; #[cfg(test)] mod tests_titlebar; #[cfg(test)] +mod tests_toast; +#[cfg(test)] mod tests_unsaved; #[cfg(test)] mod tests_wb_selector; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index daf0f08e..0cb49d83 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1069,13 +1069,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = - Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true, parent: parent_param(p) }); + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: true, + parent: parent_param(p), + include_selected: false, + }); Ok(Value::Null) } "dialog.newAlbum" => { - app.ui.dialog = - Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false, parent: parent_param(p) }); + // {name?, parent?, includeSelected? (true)} + app.ui.dialog = Some(Dialog::NewAlbum { + name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), + folder: false, + parent: parent_param(p), + include_selected: p.get("includeSelected").and_then(Value::as_bool).unwrap_or(true), + }); Ok(Value::Null) } "dialog.autoStack" => { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 23447616..53f8df73 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -2145,7 +2145,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { // a vertical mouse wheel scrolls the strip sideways (horizontal trackpad scrolls still work) child.style_mut().always_scroll_the_only_direction = true; // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` - let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active); + let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active, app.session.source); egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); app.film_scroll = Some(vp.left()); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 72528fca..9456dc2b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -608,7 +608,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } } - Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => { + Dialog::NewAlbum { name, folder, include_selected, .. } => { + if dialog_field(ui, "field:albumName", name, "Name") { + confirm = true; + } + // as in Lightroom, the option is only there when something is selected + if !*folder && !app.session.targets(&json!({})).is_empty() { + ui.add_space(6.0); + let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); + crate::widgets::register(ui.ctx(), "check:albumIncludeSelected", r.rect); + } + } + Dialog::RenameAlbum { name, .. } => { if dialog_field(ui, "field:albumName", name, "Name") { confirm = true; } @@ -1520,8 +1531,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder, "parent": parent})) + Dialog::NewAlbum { name, folder, parent, include_selected } => { + created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder && *include_selected, "parent": parent})) } Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), Dialog::TextPrompt { value, command, params, key, .. } => { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 7b6d5cf6..2d620725 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -229,7 +229,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let active = app.session.selection.active; // bring the active photo into view when it changes (keyboard, click, command) or the grid // comes back on screen; otherwise the scroll position is the user's - let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active) { + let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active, app.session.source) { active.and_then(|a| ids.iter().position(|x| *x == a)).and_then(|i| lay.cells.get(i).copied()) } else { None @@ -295,16 +295,29 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// the strip was not drawn on the previous pass (view switch, panel shown again). Otherwise the /// scroll position is the user's: time passing, finished renders, edits to the photo or a released /// scroll bar never move it (issue #11). -pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option) -> bool { +pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option, source: lightcraft_engine::LibrarySource) -> bool { let pass = ctx.cumulative_pass_nr(); - let last: Option<(Option, u64)> = ctx.data(|d| d.get_temp(key)); - ctx.data_mut(|d| d.insert_temp(key, (active, pass))); + let last: Option<(Option, lightcraft_engine::LibrarySource, u64)> = ctx.data(|d| d.get_temp(key)); + ctx.data_mut(|d| d.insert_temp(key, (active, source, pass))); match last { - Some((was, drawn)) => was != active || drawn + 1 < pass, + Some((was, where_, drawn)) => follow_changed(was, where_, drawn, active, source, pass), None => true, } } +/// Scroll when the active photo changes, the strip was not drawn last pass, or the same photo +/// sits in a different library source (another album) (#1142). +pub(crate) fn follow_changed( + was: Option, + where_was: lightcraft_engine::LibrarySource, + drawn: u64, + active: Option, + source: lightcraft_engine::LibrarySource, + pass: u64, +) -> bool { + was != active || where_was != source || drawn + 1 < pass +} + /// Height of a date header row (points). pub const HEADER_H: f32 = 40.0; @@ -1146,6 +1159,15 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { mod tests { use super::*; + #[test] + fn the_same_photo_in_another_source_scrolls_into_view() { + let id = PhotoId(3); + let all = lightcraft_engine::LibrarySource::All; + let album = lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(9)); + assert!(!follow_changed(Some(id), all, 4, Some(id), all, 5)); + assert!(follow_changed(Some(id), all, 4, Some(id), album, 5)); + } + #[test] fn ungrouped_justified_rows_fill_the_width() { let aspects = [1.5f32, 1.5, 0.66, 1.0, 1.5, 1.5, 1.5]; diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c8..108a57db 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -533,7 +533,7 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option margin_for(3000)); - assert_eq!(margin_for(48_000), MAX_MARGIN); + assert_eq!(margin_for(48_000), 2304, "4.5 % of an 8:1 frame, under the cap"); + assert_eq!(margin_for(80_000), MAX_MARGIN); assert_eq!(margin_for(0), SNAP); assert!(margin_for(usize::MAX) <= MAX_MARGIN); for l in [100, 3000, 8000, 48_000, 400_000] { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b76066d5..034e4420 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -766,12 +766,15 @@ pub enum Dialog { ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, - /// `parent`: the folder to create it in (none: the top level). + /// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album + /// holds the selected photos (Lightroom's "Include selected photos"; folders hold none). NewAlbum { name: String, folder: bool, #[serde(default)] parent: Option, + #[serde(default = "yes")] + include_selected: bool, }, RenameAlbum { id: u64, diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 25edf3bf..4d8634cc 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2129,7 +2129,7 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { h.step(); h.step(); match &mut h.app.ui.dialog { - Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None }) => *name = "Trip".into(), + Some(crate::state::Dialog::NewAlbum { name, folder: false, parent: None, .. }) => *name = "Trip".into(), other => panic!("New Album dialog expected, got {other:?}"), } h.step(); @@ -2141,3 +2141,52 @@ fn right_click_add_to_album_offers_a_new_album_when_there_is_none() { assert!(trip.photos.contains(&photo), "the right-clicked photo is in it: {:?}", trip.photos); assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); } + +/// New Album's "Include selected photos" (as in Lightroom): on by default; unticked, the new album +/// starts empty; with nothing selected the option isn't shown, and a folder never has it. +#[test] +fn new_album_can_leave_the_selected_photos_out() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let photo = lightcraft_catalog::PhotoId(h.app.session.visible_cloned()[1].0); + h.app.session.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); + let create = |h: &mut Headless, command: &str, name: &str| { + h.app.run(command, json!({"name": name})).unwrap(); + h.step(); + h.step(); + }; + let confirm = |h: &mut Headless| { + 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(), "{:?}", h.app.ui.dialog); + }; + let photos = |h: &Headless, name: &str| h.app.session.catalog.albums().find(|a| a.name == name).expect("the album was created").photos.clone(); + + // ticked by default: the selection goes in, as before + create(&mut h, "dialog.newAlbum", "With"); + assert!(has(&h, "check:albumIncludeSelected"), "the option is offered with a selection"); + confirm(&mut h); + assert_eq!(photos(&h, "With"), vec![photo]); + + // unticked: an empty album + create(&mut h, "dialog.newAlbum", "Without"); + let r = h.request("ui.clickWidget", json!({"id": "check:albumIncludeSelected"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + confirm(&mut h); + assert!(photos(&h, "Without").is_empty(), "nothing was added"); + + // a folder never offers it + create(&mut h, "dialog.newFolder", "Folder"); + assert!(!has(&h, "check:albumIncludeSelected"), "folders hold no photos"); + confirm(&mut h); + + // nothing selected: no option, an empty album + h.app.session.selection = lightcraft_engine::Selection { ids: vec![], active: None }; + create(&mut h, "dialog.newAlbum", "Nothing"); + assert!(!has(&h, "check:albumIncludeSelected"), "no selection, no option"); + confirm(&mut h); + assert!(photos(&h, "Nothing").is_empty()); +} diff --git a/crates/ui-egui/src/tests_toast.rs b/crates/ui-egui/src/tests_toast.rs new file mode 100644 index 00000000..cf671d24 --- /dev/null +++ b/crates/ui-egui/src/tests_toast.rs @@ -0,0 +1,58 @@ +//! The toast is drawn as widgets, not as paint. That is what makes its message selectable: a toast +//! often carries a path or a command the user wants somewhere else, and painted text cannot be +//! picked up by the pointer at all. + +use std::time::Duration; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + // let the first renders finish: a render still running when the test binary exits can crash in the + // GPU driver's teardown (the preview pool doesn't join its workers) + h.settle(T); + h +} + +/// One event per frame: [`LightcraftApp::raw_input_hook`] takes pointer events singly. +fn send(h: &mut Headless, e: egui::Event) { + h.app.synthetic.push(e); + h.step(); +} + +fn show_toast(h: &mut Headless, msg: &str) -> egui::Rect { + h.app.ui.toast = Some((msg.to_owned(), f64::MAX, None)); + h.step(); + h.step(); + h.view.ctx.memory(|m| m.area_rect(egui::Id::new("toast"))).expect("the toast is laid out as a widget, not paint") +} + +#[test] +fn the_toast_is_a_widget_the_pointer_reaches() { + let mut h = demo(); + let msg = "Exported frame to /tmp/scene-0001.png"; + let area = show_toast(&mut h, msg); + + assert!(area.is_positive(), "the toast has a rect of its own: {area:?}"); + assert!(h.painted_text().iter().any(|t| t.contains("Exported frame")), "the message is still painted"); + + // The pointer reaches the message: egui offers the text cursor over it. Painted text is never + // hit-tested, so this is the difference that makes selection possible at all. + send(&mut h, egui::Event::PointerMoved(area.center())); + assert_eq!(h.last_cursor, egui::CursorIcon::Text, "the toast text is a text target"); +} + +#[test] +fn the_toast_still_wraps_a_long_message_inside_the_canvas() { + let mut h = demo(); + let msg = "The SAM 3 download failed: the file is 4.1 GB and the connection dropped at 98 %, so nothing was installed and the masks are still unavailable"; + let area = show_toast(&mut h, msg); + + let canvas = h.app.canvas_rect.expect("the demo has a canvas"); + assert!(area.width() <= canvas.width() - 80.0, "the toast wraps within the canvas: {area:?}"); + assert!(h.painted_text().iter().any(|t| t.contains("SAM 3 download failed")), "the whole message is painted"); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4..23e31d0d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -94,7 +94,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | -| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`) | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service |