LRC-LIB-LIBFOLDERS: a count never covers a name in a narrow panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-28 11:44:38 -04:00
co-authored by Claude Opus 5.5
parent 59a5d00c60
commit e2043f44df
5 changed files with 105 additions and 9 deletions
+41 -6
View File
@@ -1,4 +1,5 @@
use super::albums::MARK_W;
use super::sections::visible_right;
use super::{Align2, Icon, LightcraftApp, Rect, Sense, Tokens, paint, pos2, register, vec2};
/// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are
@@ -22,6 +23,10 @@ pub(super) const SCROLL_TO_ROW: &str = "left-scroll-to-row";
/// removed, they open again.
pub(super) const LIBFOLDER_REVEALED: &str = "libfolder-revealed";
/// The least of its name a row shows before its count gives way (about three letters and an
/// ellipsis).
const MIN_COUNT_NAME_ROOM: f32 = 36.0;
/// A row says how wide it needs to be.
pub(super) fn note_width(ui: &egui::Ui, w: f32) {
ui.data_mut(|d| {
@@ -30,6 +35,20 @@ pub(super) fn note_width(ui: &egui::Ui, w: f32) {
});
}
/// Where a row's count (and label dot) go when the visible `edge` leaves too little room for the
/// name: after the name (at least a bit of it, [`MIN_COUNT_NAME_ROOM`], or all of a shorter name)
/// and wholly past the edge, never cut in two by it, reached by scrolling sideways. `None` when
/// they stay at the edge, which a row with room (or with neither count nor dot, which has
/// nothing to keep whole) always does. A row asks for that much width, drawn or not, so from the
/// next frame on the content reaches it and the clamp to the row no longer bites; the name's
/// share is at most what [`row_need`] already asks for, so scrolling to the count never narrows
/// the content and sends it back.
fn count_past_edge(label_left: f32, name_w: f32, mark_w: f32, count_w: Option<f32>, has_mark: bool, edge: f32) -> Option<f32> {
let w = count_w.unwrap_or(0.0);
let after_name = label_left + name_w.min(MIN_COUNT_NAME_ROOM) + 8.0 + mark_w + w + 18.0;
(after_name > edge && (count_w.is_some() || has_mark)).then(|| after_name.max(edge + 4.0 + mark_w + w + 18.0))
}
/// One level of a sidebar tree when the panel has room: callers pass `indent` in these steps.
pub(crate) const INDENT_STEP: f32 = 16.0;
@@ -175,8 +194,13 @@ fn draw_row(app: &mut LightcraftApp, ui: &mut egui::Ui, row: Row) -> egui::Respo
let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim };
let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n)));
let label_left = r.left() + 42.0 + indent;
let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x);
let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x;
let full_w = measure(label);
// a label's dot sits just before the count
let count_w = count_galley.as_ref().map(|g| g.size().x);
let past_edge = count_past_edge(label_left, full_w, mark_w, count_w, mark.is_some(), edge);
let edge = past_edge.map_or(edge, |e| e.min(r.right()));
let count_left = edge - 18.0 - count_w.unwrap_or(0.0);
if let Some(c) = mark {
let dot = Rect::from_center_size(pos2(count_left - MARK_W / 2.0, r.center().y), vec2(8.0, 8.0));
ui.painter().circle_filled(dot.center(), 4.0, c);
@@ -184,18 +208,20 @@ fn draw_row(app: &mut LightcraftApp, ui: &mut egui::Ui, row: Row) -> egui::Respo
}
// the name gives way to the count: cut with an ellipsis, in full on hover
let room = count_left - mark_w - 8.0 - label_left;
let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x;
let full_w = measure(label);
let shown = if full_w <= room { label.to_string() } else { crate::widgets::elide_head(label, room.max(0.0), measure) };
let label_rect = ui.painter().text(pos2(label_left, r.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color);
register(ui, format!("label:{id}"), label_rect);
let resp = if shown != label { resp.on_hover_text(label) } else { resp };
note_width(ui, row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x)));
let mut needed = row_need(indent, full_w, mark_w, count_galley.as_ref().map(|g| g.size().x));
if let Some(galley) = count_galley {
let rect = Rect::from_min_size(pos2(edge - 18.0 - galley.size().x, r.center().y - galley.size().y / 2.0), galley.size());
register(ui, format!("count:{id}"), rect);
ui.painter().galley(rect.min, galley, t.text_dim);
}
if let Some(e) = past_edge {
needed = needed.max(e - r.left());
}
note_width(ui, needed);
resp
}
@@ -216,7 +242,14 @@ pub(super) fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, lab
let painter = ui.painter();
let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x;
let count_w = count.filter(|_| app.ui.show_counts).map(|n| painter.layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim).size().x);
note_width(ui, row_need(indent, name_w, if mark { MARK_W } else { 0.0 }, count_w));
let mark_w = if mark { MARK_W } else { 0.0 };
let mut needed = row_need(indent, name_w, mark_w, count_w);
// a row that drawn would keep its count past the edge asks for the same width as when drawn
let edge = r.right().min(visible_right(ui));
if let Some(e) = count_past_edge(r.left() + 42.0 + indent, name_w, mark_w, count_w, mark, edge) {
needed = needed.max(e - r.left());
}
note_width(ui, needed);
true
}
@@ -229,7 +262,9 @@ fn scroll_asked(ui: &egui::Ui, id: &str) -> bool {
/// room for its name up to a share of a panel, so one very long name does not make everything
/// scroll: depth does that.
fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option<f32>) -> f32 {
42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0)
// (a colour dot with no count keeps the gap after the name that a count's 16 pt includes)
let after = count_w.map_or(if mark_w > 0.0 { 8.0 } else { 0.0 }, |w| w + 16.0);
42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + after
}
/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing
+1 -1
View File
@@ -51,7 +51,7 @@ pub(super) fn section(
/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider
/// than the panel while the sidebar scrolls sideways.
fn visible_right(ui: &egui::Ui) -> f32 {
pub(super) fn visible_right(ui: &egui::Ui) -> f32 {
ui.data(|d| d.get_temp(egui::Id::unique("left-visible-right"))).unwrap_or(f32::MAX)
}
+55
View File
@@ -1600,6 +1600,61 @@ fn a_chain_of_single_folders_starts_open() {
assert!(!has(&h, "source:libfolder:/Users/me/Pictures/2024"), "the branching folder waits to be opened");
}
/// In a panel too narrow for a deep row, the count gives way: it is drawn after the name, never
/// over it or over the folder's icon.
#[test]
fn a_count_never_covers_the_name_of_a_deep_row() {
// fourteen levels, names wider than the room a row keeps for its name
let names: Vec<String> = (1..=14).map(|i| format!("level-{i:02}")).collect();
let mut paths = Vec::new();
let mut dir = String::new();
for name in &names {
dir.push('/');
dir.push_str(name);
paths.push(format!("{dir}/1.jpg"));
}
// (enough photos at the bottom for counts five digits wide all the way up)
paths.extend((0..12_000).map(|i| format!("{dir}/more-{i}.jpg")));
let refs: Vec<&str> = paths.iter().map(String::as_str).collect();
// (tall enough for every row of the chain to be drawn: rows below the fold are not)
let mut h = folders_app_sized(&refs, [1400.0, 1500.0]);
let left = widget(&h, "panel:left_panel");
drag(&mut h, left.right() - 2.0, -1400.0);
assert_eq!(h.app.ui.left_width, crate::state::LEFT_WIDTH.min, "the panel at its narrowest");
let mut path = String::new();
for name in &names[..names.len() - 1] {
path.push('/');
path.push_str(name);
click(&mut h, &format!("libraryFolderToggle:{path}"));
}
h.settle(SETTLE);
let deep = dir.as_str();
let (label, count) = (widget(&h, &format!("label:libfolder:{deep}")), widget(&h, &format!("count:libfolder:{deep}")));
assert!(label.width() > 0.0, "the name is shown: {label:?}");
assert!(count.left() >= label.right(), "the count comes after the name: {label:?} {count:?}");
// a row with room keeps its count at the panel's visible edge
let panel = widget(&h, "panel:left_panel");
assert!(widget(&h, "count:all").right() <= panel.right());
// and no count is cut in two by that edge (5 digits wide here): it is whole, inside or past it
let mut past = 0;
for (id, r) in h.app.widgets.iter().filter(|(w, _)| w.starts_with("count:libfolder:")) {
assert!(r.right() <= panel.right() || r.left() >= panel.right(), "{id} straddles the edge: {r:?} / {panel:?}");
past += usize::from(r.left() >= panel.right());
}
assert!(past > 0, "the deepest rows' counts are past the edge");
// at every width, also where the content is only a little wider than the panel (a count that
// gave way used to be held back by the row's own end there, and was cut by the edge)
for width in (200..=330).step_by(2) {
let r = h.request("ui.set", json!({"leftWidth": width}), T);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
let panel = widget(&h, "panel:left_panel");
for (id, r) in h.app.widgets.iter().filter(|(w, _)| w.starts_with("count:libfolder:")) {
assert!(r.right() <= panel.right() || r.left() >= panel.right(), "at {width} pt {id} straddles the edge: {r:?} / {panel:?}");
}
}
}
/// Removing a folder from the library asks first, then moves its photos to Recently Deleted.
#[test]
fn removing_a_folder_from_the_library_asks_first() {
File diff suppressed because one or more lines are too long
+5
View File
@@ -157,6 +157,11 @@
the full count took about 14 s instead of about 23 s on a heavily loaded machine; the rest is the text rules of
the albums themselves. The counts are the same as before, including for albums that loop through each other.
### A count never covers a name in the sidebar
- In the left panel, the count of a deeply indented row (a folder, keyword or album) no longer prints over its icon
and name when the panel is narrow. It follows the name, wholly past the panel's edge, and scrolling sideways
reaches it; so does the colour dot of a labelled folder. A row with room for its name keeps its count at the edge, as before.
### Sharper resampling in HDR and panorama merges
- Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a
photo by half a pixel used to lose a third of its finest detail; now it keeps most of it, without halos around