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.
This commit is contained in:
Zinadin Zidan
2026-11-06 04:09:48 +06:00
parent 16f8f7e8a0
commit c97fee7b2f
2 changed files with 114 additions and 19 deletions
+30 -16
View File
@@ -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 {
+84 -3
View File
@@ -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<egui::Event>| {
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<Rect>) {
match shape {
egui::Shape::Text(text) => out.push(text.visual_bounding_rect()),