Merge PR #2696: LRC-LIB-LIBFOLDERS: a count never covers a name in a narrow sidebar

Conflicts in docs/parity-checklist.md docs/whats-new.md: kept both sides (union).
This commit is contained in:
aiongg
2026-10-31 02:07:00 +00:00
5 changed files with 228 additions and 12 deletions
+43 -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,15 @@ 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);
// (held to the row's end only when that ends inside the panel: a row that reaches past the
// panel's edge keeps the count wholly past it, not cut by it)
let edge = past_edge.map_or(edge, |e| if r.right() < visible_right { e.min(r.right()) } else { e });
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 +210,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 +244,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 +264,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)
}
+177 -3
View File
@@ -1600,6 +1600,180 @@ fn a_chain_of_single_folders_starts_open() {
assert!(!has(&h, "source:libfolder:/Users/me/Pictures/2024"), "the branching folder waits to be opened");
}
/// The sidebar at its narrowest, with fourteen folders open one inside the other, names wider than
/// the room a row keeps for its name, counts five digits wide all the way up, and a color label on
/// the deepest folder (a dot just before its count). Returns the deepest folder's path.
fn folders_app_with_deep_labeled_chain() -> (Headless, String) {
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);
let toggle = format!("libraryFolderToggle:{path}");
assert!(h.step_until(T, |h| has(h, &toggle)), "{toggle} is drawn");
let r = h.request("ui.clickWidget", json!({"id": toggle}), T);
assert_eq!(r["ok"], true, "{r}");
// the frame a new row is first drawn in, before the content has grown to what it asked for
h.step();
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(), "opening {path}: {id} straddles the edge: {r:?} / {panel:?}");
}
h.step();
}
h.app.run("folder.label", json!({"path": dir, "label": "red"})).unwrap();
h.settle(SETTLE);
(h, dir)
}
/// 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() {
let (mut h, deep) = folders_app_with_deep_labeled_chain();
let deep = deep.as_str();
let (label, count) = (widget(&h, &format!("label:libfolder:{deep}")), widget(&h, &format!("count:libfolder:{deep}")));
let mark = widget(&h, &format!("labelMark: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:?}");
assert!(
mark.left() >= label.right() && mark.right() <= count.left(),
"the color dot sits between the name and the count: {label:?} {mark:?} {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:") || w.starts_with("labelMark:libfolder:")) {
assert!(r.right() <= panel.right() || r.left() >= panel.right(), "at {width} pt {id} straddles the edge: {r:?} / {panel:?}");
}
// and the deepest row's color dot and count still come after its name
let (label, count) = (widget(&h, &format!("label:libfolder:{deep}")), widget(&h, &format!("count:libfolder:{deep}")));
let mark = widget(&h, &format!("labelMark:libfolder:{deep}"));
assert!(
mark.left() >= label.right() && mark.right() <= count.left(),
"at {width} pt the dot and count come after the name: {label:?} {mark:?} {count:?}"
);
}
}
/// A count or dot that gives way is whole at the panel's edge in every frame, also the one right
/// after the panel's width changes, before the content has grown to the room the row asked for.
#[test]
fn a_count_that_gives_way_is_whole_in_the_frame_the_panel_changes() {
let (mut h, _) = folders_app_with_deep_labeled_chain();
let up: Vec<i32> = (200..=330).step_by(2).collect();
for width in up.iter().chain(up.iter().rev()) {
let r = h.request("ui.set", json!({"leftWidth": width}), T);
assert_eq!(r["ok"], true, "{r}");
// (one frame, not settled)
h.step();
let panel = widget(&h, "panel:left_panel");
for (id, r) in h.app.widgets.iter().filter(|(w, _)| w.starts_with("count:libfolder:") || w.starts_with("labelMark:libfolder:")) {
assert!(r.right() <= panel.right() || r.left() >= panel.right(), "one frame after {width} pt {id} straddles the edge: {r:?} / {panel:?}");
}
}
}
/// A sideways scroll of many points in one frame (a flick) does not leave a count or dot cut in
/// two by the panel's edge in the frame it lands.
#[test]
fn a_count_that_gives_way_is_whole_after_a_sideways_flick() {
let (mut h, _) = folders_app_with_deep_labeled_chain();
h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T);
for dx in [-10.0, -20.0, -30.0, -40.0, -60.0, -90.0, 20.0, 40.0, 90.0] {
for width in [210, 230, 250, 290] {
h.request("ui.set", json!({"leftWidth": width}), T);
h.settle(SETTLE);
h.request("ui.scroll", json!({"dx": dx}), T);
h.step();
let panel = widget(&h, "panel:left_panel");
for (id, r) in h.app.widgets.iter().filter(|(w, _)| w.starts_with("count:libfolder:") || w.starts_with("labelMark:libfolder:")) {
assert!(
r.right() <= panel.right() || r.left() >= panel.right(),
"flick {dx} at {width} pt: {id} straddles the edge: {r:?} / {panel:?}"
);
}
}
}
}
/// The same when the panel grows by more than a few points in one frame (a window resize, or a
/// drag that was held up): the content is still as wide as the last frame asked for.
#[test]
fn a_count_that_gives_way_is_whole_when_the_panel_grows_in_one_frame() {
let (mut h, _) = folders_app_with_deep_labeled_chain();
for from in (200..=260).step_by(10) {
for by in [8, 16, 24, 32, 48, 64] {
for width in [from, from + by] {
let r = h.request("ui.set", json!({"leftWidth": width}), T);
assert_eq!(r["ok"], true, "{r}");
if width == from {
h.settle(SETTLE);
} else {
h.step();
}
}
let panel = widget(&h, "panel:left_panel");
assert!((panel.width() - (from + by) as f32).abs() < 2.0, "the panel is already {} pt wide in that frame: {panel:?}", from + by);
for (id, r) in h.app.widgets.iter().filter(|(w, _)| w.starts_with("count:libfolder:") || w.starts_with("labelMark:libfolder:")) {
assert!(
r.right() <= panel.right() || r.left() >= panel.right(),
"one frame after {from} -> {} pt {id} straddles the edge: {r:?} / {panel:?}",
from + by
);
}
}
}
}
/// Scrolling the sidebar sideways, a frame at a time, never leaves a count or dot cut in two by
/// the panel's edge, also where the scroll reaches the end of the content.
#[test]
fn a_count_that_gives_way_is_whole_while_the_sidebar_scrolls_sideways() {
let (mut h, _) = folders_app_with_deep_labeled_chain();
h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T);
for (dx, frames) in [(-6.0, 80), (6.0, 80)] {
for frame in 0..frames {
let r = h.request("ui.scroll", json!({"dx": dx}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
let panel = widget(&h, "panel:left_panel");
for (id, r) in h.app.widgets.iter().filter(|(w, _)| w.starts_with("count:libfolder:") || w.starts_with("labelMark:libfolder:")) {
assert!(r.right() <= panel.right() || r.left() >= panel.right(), "frame {frame} of {dx}: {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() {
@@ -1895,7 +2069,7 @@ fn removing_a_disk_from_the_library_asks_first() {
/// A library with a photo in every folder down `deep` and two folders below it, in a window
/// `size` big, with every level but the last opened. (Every level holds a photo, so it has a row
/// of its own and starts closed, whether or not the sidebar passes over folders that only lead on.)
fn deep_folders_app(deep: &str, size: [f32; 2]) -> Headless {
fn narrow_chain_of_folders_app(deep: &str, size: [f32; 2]) -> Headless {
let mut paths = vec![format!("{deep}/x/1.jpg"), format!("{deep}/y/2.jpg")];
let mut levels = Vec::new();
let mut level = String::new();
@@ -1938,7 +2112,7 @@ fn chevron_xs(h: &Headless, path: &str) -> Vec<f32> {
fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() {
let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z";
// (tall enough for the whole chain: Recently Deleted is above it now)
let mut h = deep_folders_app(deep, [1400.0, 1700.0]);
let mut h = narrow_chain_of_folders_app(deep, [1400.0, 1700.0]);
let xs = chevron_xs(&h, deep);
let step = crate::panels::left::MIN_INDENT_STEP;
assert_eq!(crate::panels::left::indent_step(&h.view.ctx), step, "26 levels do not fit the default panel even at the narrowest step");
@@ -1964,7 +2138,7 @@ fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() {
#[test]
fn a_deep_library_path_narrows_the_indent_instead_of_cutting_the_names() {
let deep = "/Users/someone/Library/CloudStorage/Nextcloud-cloud.example.com/RAW/Club/2026-09-24 Team/RAW";
let mut h = deep_folders_app(deep, [1400.0, 1200.0]);
let mut h = narrow_chain_of_folders_app(deep, [1400.0, 1200.0]);
let full = crate::panels::left::INDENT_STEP;
let narrow = crate::panels::left::indent_step(&h.view.ctx);
assert!(narrow < full && narrow >= crate::panels::left::MIN_INDENT_STEP, "the step narrows in the default panel: {narrow}");
File diff suppressed because one or more lines are too long
+4
View File
@@ -185,6 +185,10 @@
you make, or test each other 32 deep, keeps testing the rules as written.) On a 312,000-photo test library, opening
51 such albums in turn went from about 19 s to about 5.8 s (about 377 ms to 113 ms each; a busy machine,
so rough numbers).
### 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