From 16f8f7e8a0b89e7f00dc94ce2274845fd70078f9 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Fri, 6 Nov 2026 01:34:21 +0600 Subject: [PATCH 1/2] Grid: one selected photo shows "1 selected" in the header (#3776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Library grid's header added the selection count only from two selected photos up, so selecting one showed nothing, while the reference app shows "1 selected". It now shows from one. The Spanish, Brazilian Portuguese and Swedish strings were plural-only ("1 seleccionadas"); they now read "Seleccionadas: 1 · ...", the noun-first form Italian and Polish already use. --- crates/ui-egui/locales/es-formats.json | 2 +- crates/ui-egui/locales/pt-br-formats.json | 2 +- crates/ui-egui/locales/sv-formats.json | 2 +- crates/ui-egui/src/panels/grid.rs | 2 +- .../ui-egui/src/panels/grid/header_tests.rs | 35 +++++++++++++++++++ 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 7b697e946b..32547eebe8 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -169,7 +169,7 @@ "{n} photo{} now · updates automatically as photos change": "{n} foto{} ahora · se actualiza automáticamente cuando cambian las fotos", "{n} selected photo{} · {pages} page{}": "{n} foto{0} seleccionada{0} · {pages} página{1}", "{pid} (missing)": "{pid} (falta)", - "{sel_n} selected · {counted}": "{sel_n} seleccionadas · {counted}", + "{sel_n} selected · {counted}": "Seleccionadas: {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} {n} foto{}", "{weekday}, {day}": "{weekday}, {day}", diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 83d5a37518..e547bee56a 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -128,7 +128,7 @@ "{n} photo{} match · updates automatically as photos change": "{n} foto{0} correspondente{0} · atualiza automaticamente conforme as fotos mudam", "{n} photo{} now · updates automatically as photos change": "{n} foto{} agora · atualiza automaticamente conforme as fotos mudam", "{n} selected photo{} · {pages} page{}": "{n} foto{0} selecionada{0} · {pages} página{1}", - "{sel_n} selected · {counted}": "{sel_n} selecionadas · {counted}", + "{sel_n} selected · {counted}": "Selecionadas: {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} {n} foto{}", "{weekday}, {day}": "{weekday}, {day}", diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json index 7d40388d1b..68995acf5c 100644 --- a/crates/ui-egui/locales/sv-formats.json +++ b/crates/ui-egui/locales/sv-formats.json @@ -253,7 +253,7 @@ "{pct} %": "{pct} %", "{photos} catalog records processed; {collections} collections.": "{photos} katalogposter bearbetade; {collections} samlingar.", "{pid} (missing)": "{pid} (saknas)", - "{sel_n} selected · {counted}": "{sel_n} markerade · {counted}", + "{sel_n} selected · {counted}": "Markerade: {sel_n} · {counted}", "{size} GB": "{size} GB", "{stage}… {:.0}%": "{stage}… {:.0} %", "{s} s of AV1 video": "{s} s AV1-video", diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index db4dfa1b83..2c16c38047 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -298,7 +298,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); let total = app.session.source_total(); let counted = super::chips::count_text(ids.len(), total, !chips.is_empty()); - let cnt = if sel_n > 1 { crate::i18n::tr_format!("{sel_n} selected · {counted}", counted = counted, sel_n = sel_n) } else { counted }; + let cnt = if sel_n > 0 { crate::i18n::tr_format!("{sel_n} selected · {counted}", counted = counted, sel_n = sel_n) } else { counted }; match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) { Some(b) => { let local = app.caches.grid.local(&app.session.catalog, &ids, generation); diff --git a/crates/ui-egui/src/panels/grid/header_tests.rs b/crates/ui-egui/src/panels/grid/header_tests.rs index 463991625e..88b992d3b4 100644 --- a/crates/ui-egui/src/panels/grid/header_tests.rs +++ b/crates/ui-egui/src/panels/grid/header_tests.rs @@ -17,6 +17,41 @@ fn breadcrumbs_open_their_folder_as_spelled() { assert!(to("").is_empty() && to("/").is_empty() && to(r"\\").is_empty()); } +/// Issue #3776: one selected photo is counted in the header as two or more are ("1 selected · 3 +/// photos"); with nothing selected the header shows the photo count alone. +#[test] +fn the_header_counts_a_single_selected_photo() { + use lightcraft_catalog::{Op, Photo, Source}; + for selected in [0, 1, 2] { + let mut session = lightcraft_engine::Session::new(); + for i in 0..3 { + let id = session.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: format!("/pics/{i}.jpg") }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + session.selection.ids = session.catalog.photos().take(selected).map(|p| p.id).collect(); + let mut app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + app.ui.view = crate::state::ViewMode::PhotoGrid; + let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); + h.step(); + h.step(); + assert_eq!(h.app.session.selection.ids.len(), selected, "the selection is kept"); + let painted = h.painted_text(); + let expected = if selected == 0 { "3 photos".to_string() } else { format!("{selected} selected · 3 photos") }; + assert!(painted.contains(&expected), "{selected} selected: the header shows {expected:?}: {painted:?}"); + if selected == 0 { + assert!(!painted.iter().any(|s| s.contains("selected ·")), "nothing selected, no count: {painted:?}"); + } + } + // a language whose word for "selected" agrees with the number reads "noun: number", right for one as for many + for (locale, expected) in [(Locale::Es, "Seleccionadas: 1 · x"), (Locale::PtBr, "Selecionadas: 1 · x"), (Locale::Sv, "Markerade: 1 · x")] { + crate::i18n::set_language(locale); + let one = crate::i18n::tr_format!("{sel_n} selected · {counted}", sel_n = 1, counted = "x"); + crate::i18n::set_language(Locale::En); + assert_eq!(one, expected, "{locale:?}"); + } +} + fn text_bounds(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(text) => out.push(text.visual_bounding_rect()), From c97fee7b2f79d1b473e8c446d2cd3121ad8a6bc7 Mon Sep 17 00:00:00 2001 From: Zinadin Zidan Date: Fri, 6 Nov 2026 04:09:48 +0600 Subject: [PATCH 2/2] Grid: the library header's selection count is cut, not clipped, when it is wider than the header (#3776) In the library view the count was painted right-aligned at any width, so at the narrowest pane a long count started outside the header and lost its beginning. It now truncates with "..." and shows the full text on hover, as the folder header's count already did; the title still gives way first. The header moved into its own function so a test can render it at a set width, and the folder view's one-photo count is tested too. --- crates/ui-egui/src/panels/grid.rs | 46 ++++++---- .../ui-egui/src/panels/grid/header_tests.rs | 87 ++++++++++++++++++- 2 files changed, 114 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 2c16c38047..556f809d27 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -291,7 +291,6 @@ pub fn rows_between(rows: &[GridRow], top: f32, bottom: f32) -> &[GridRow] { } fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); let (generation, ids) = app.session.visible_shared(); // header: source title + count let sel_n = app.session.selection.ids.len(); @@ -305,21 +304,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { folder_header(app, ui, &b, &ids, local, &cnt); } None => { - let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); - ui.painter().rect_filled(hr, 0.0, t.canvas); - let full = crate::i18n::source_title(&app.session); - let count_w = ui.painter().layout_no_wrap(cnt.clone(), t.font(12.5), t.text_dim).size().x; - // the title gives way to the count: a long album or folder name is trimmed, in full on hover - let room = (hr.width() - 20.0 - count_w - 36.0).max(0.0); - let font = t.semibold(17.0); - let title = crate::widgets::elide_head(&full, room, |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), t.text).size().x); - let title_rect = ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, font, t.text); - let count_rect = ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim); - crate::widgets::register(ui, "grid:title", title_rect); - crate::widgets::register(ui, "grid:count", count_rect); - if title != full { - ui.interact(title_rect, ui.scope_id().with("grid-title"), Sense::hover()).on_hover_text(full); - } + library_header(app, ui, &cnt); } } super::chips::show(app, ui, &chips); @@ -1016,6 +1001,35 @@ struct BadgeFlags { hovered: bool, } +/// The header of a library view (All Photos, an album, a library folder…): the source's title and +/// the photo count. +fn library_header(app: &LightcraftApp, ui: &mut egui::Ui, cnt: &str) -> Rect { + let t = Tokens::get(ui.ctx()); + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); + ui.painter().rect_filled(hr, 0.0, t.canvas); + let full = crate::i18n::source_title(&app.session); + // the title gives way to the count: a long album or folder name is trimmed, in full on hover; + // a count wider than all but the title's ellipsis (a narrow centre pane) is cut, as a Local + // folder's is, and reads in full on hover + let count_w = ui.painter().layout_no_wrap(cnt.to_owned(), t.font(12.5), t.text_dim).size().x.ceil().min((hr.width() - 96.0).max(0.0).floor()); + let room = (hr.width() - 20.0 - count_w - 36.0).max(0.0); + let font = t.semibold(17.0); + let title = crate::widgets::elide_head(&full, room, |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), t.text).size().x); + let title_rect = ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, font, t.text); + let count_box = Rect::from_min_max(pos2(hr.right() - 20.0 - count_w, hr.top()), pos2(hr.right() - 20.0, hr.bottom())); + let label = egui::Label::new(egui::RichText::new(cnt).font(t.font(12.5)).color(t.text_dim)).truncate().show_tooltip_when_elided(false); + let count = ui + .scope_builder(egui::UiBuilder::new().max_rect(count_box).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| ui.add(label)) + .inner; + crate::widgets::register(ui, "grid:title", title_rect); + crate::widgets::register(ui, "grid:count", count.rect); + count.on_hover_text(cnt); + if title != full { + ui.interact(title_rect, ui.scope_id().with("grid-title"), Sense::hover()).on_hover_text(full); + } + hr +} + /// The header of a Local folder view: the path as a breadcrumb (each part opens that folder), /// Include subfolders, Add to My Photos. fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_engine::Browse, ids: &[PhotoId], local_n: usize, cnt: &str) -> Rect { diff --git a/crates/ui-egui/src/panels/grid/header_tests.rs b/crates/ui-egui/src/panels/grid/header_tests.rs index 88b992d3b4..db5cd37ffe 100644 --- a/crates/ui-egui/src/panels/grid/header_tests.rs +++ b/crates/ui-egui/src/panels/grid/header_tests.rs @@ -22,7 +22,7 @@ fn breadcrumbs_open_their_folder_as_spelled() { #[test] fn the_header_counts_a_single_selected_photo() { use lightcraft_catalog::{Op, Photo, Source}; - for selected in [0, 1, 2] { + for (selected, folder) in [(0, false), (1, false), (2, false), (0, true), (1, true)] { let mut session = lightcraft_engine::Session::new(); for i in 0..3 { let id = session.catalog.alloc_photo_id(); @@ -30,15 +30,24 @@ fn the_header_counts_a_single_selected_photo() { session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); } session.selection.ids = session.catalog.photos().take(selected).map(|p| p.id).collect(); + if folder { + // a Local folder view: the same count, in the breadcrumb header + session.source = lightcraft_engine::LibrarySource::Folder; + session.browse = Some(lightcraft_engine::Browse { path: "/pics".into(), subfolders: false }); + } let mut app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); app.ui.view = crate::state::ViewMode::PhotoGrid; let mut h = crate::headless::Headless::new(app, [1400.0, 900.0], 1.0); h.step(); h.step(); assert_eq!(h.app.session.selection.ids.len(), selected, "the selection is kept"); + let has = |id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + assert!(has("grid:count") && has("grid:folderHeader") == folder, "folder {folder}: {:?}", h.app.widgets); let painted = h.painted_text(); - let expected = if selected == 0 { "3 photos".to_string() } else { format!("{selected} selected · 3 photos") }; - assert!(painted.contains(&expected), "{selected} selected: the header shows {expected:?}: {painted:?}"); + let photos = crate::panels::chips::count_text(h.app.session.visible_cloned().len(), h.app.session.source_total(), false); + assert!(folder || photos == "3 photos", "{photos}"); + let expected = if selected == 0 { photos } else { format!("{selected} selected · {photos}") }; + assert!(painted.contains(&expected), "folder {folder}, {selected} selected: the header shows {expected:?}: {painted:?}"); if selected == 0 { assert!(!painted.iter().any(|s| s.contains("selected ·")), "nothing selected, no count: {painted:?}"); } @@ -52,6 +61,78 @@ fn the_header_counts_a_single_selected_photo() { } } +/// Every text the frame painted as it reads on screen (a cut one with its "…"), with where it was painted. +fn texts_painted(shape: &egui::Shape, out: &mut Vec<(String, Rect)>) { + match shape { + egui::Shape::Text(text) => { + let shown = text.galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| g.chr)).collect(); + out.push((shown, text.visual_bounding_rect())); + } + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| texts_painted(s, out)), + _ => {} + } +} + +/// A library view's count too long for a narrow centre pane (German, at the narrowest the photo +/// area gets) is cut to stay inside the header, beside the title, and reads in full on hover. +#[test] +fn a_long_library_count_stays_inside_a_narrow_header() { + for width in [crate::state::MIN_PHOTO_WIDTH, 590.0] { + for scale in [1.0, 2.0] { + let app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let ctx = egui::Context::default(); + crate::i18n::set_language(Locale::De); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let counted = crate::i18n::tr_format!("{matching} of {t} photos", matching = 123_456_789, t = 987_654_321); + let cnt = crate::i18n::tr_format!("{sel_n} selected · {counted}", sel_n = 123_456_789, counted = counted); + let frame = |time: f64, events: Vec| { + let mut header = Rect::NOTHING; + let raw = HeadlessView::raw_input(vec2(width, 300.0), scale, time, events); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + header = super::library_header(&app, ui, &cnt); + }); + }); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for shape in &out.shapes { + texts_painted(&shape.shape, &mut texts); + } + (texts, crate::widgets::take_registry(&ctx), header) + }; + let (mut texts, mut widgets, mut header) = (Vec::new(), Vec::new(), Rect::NOTHING); + for f in 0..3 { + (texts, widgets, header) = frame(f as f64 / 60.0, vec![]); + } + let widget = |id: &str| widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r); + let (Some(count), Some(title)) = (widget("grid:count"), widget("grid:title")) else { panic!("no count or title: {widgets:?}") }; + let at = format!("{width} {scale}"); + assert!(header.left() >= 0.0 && header.right() <= width, "{at}: header must not expand past the centre pane: {header:?}"); + assert!(header.contains_rect(count), "{at}: the count stays inside the header: {count:?} in {header:?}"); + assert!(title.right() <= count.left(), "{at}: the title stays left of the count: {title:?} {count:?}"); + for (i, (text, a)) in texts.iter().enumerate() { + assert!(header.expand(1.0).contains_rect(*a), "{at}: {text:?} painted outside the header: {a:?}"); + for (other, b) in texts.iter().skip(i + 1) { + assert!(!a.intersects(*b), "{at}: {text:?} overlaps {other:?}"); + } + } + // cut where it doesn't fit, and in full on hover + let fits = width > 500.0; + assert_eq!(texts.iter().any(|(t, _)| *t == cnt), fits, "{at}: {texts:?}"); + let cut = cnt.split(" von ").next().unwrap_or_default(); + assert!(fits || texts.iter().any(|(t, _)| t.ends_with('…') && t.starts_with(cut)), "{at}: the count is cut at its end: {texts:?}"); + let mut shown = Vec::new(); + for f in 3..120 { + let events = if f == 3 { vec![egui::Event::PointerMoved(count.center())] } else { vec![] }; + (shown, _, _) = frame(f as f64 / 60.0, events); + } + assert!(shown.iter().any(|(t, _)| *t == cnt), "{at}: the full count shows on hover: {shown:?}"); + crate::i18n::set_language(Locale::En); + } + } +} + fn text_bounds(shape: &egui::Shape, out: &mut Vec) { match shape { egui::Shape::Text(text) => out.push(text.visual_bounding_rect()),