mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 17:31:09 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user