mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 22:17:53 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user