mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #2349: LRC-LIB-LIBFOLDERS: the sidebar trees narrow their indent step before names are cut (#2298)
docs/parity-checklist.md: header and revision rows from both sides
This commit is contained in:
@@ -36,6 +36,53 @@ fn note_width(ui: &egui::Ui, w: f32) {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// The narrowest a level is drawn: the chevron still steps visibly right of its parent's.
|
||||
pub(crate) const MIN_INDENT_STEP: f32 = 8.0;
|
||||
|
||||
/// What a row keeps for its name when the step is chosen: the deepest row's name is not cut to
|
||||
/// less than this while a narrower step would still keep it.
|
||||
const MIN_NAME_ROOM: f32 = 80.0;
|
||||
|
||||
/// Room kept for a count at the end of the deepest row (three digits and their gap).
|
||||
const COUNT_ROOM: f32 = 40.0;
|
||||
|
||||
/// The step a level is drawn with this frame: [`INDENT_STEP`] while the deepest open row fits the
|
||||
/// panel, narrower when it would not, never below [`MIN_INDENT_STEP`]. From the last frame.
|
||||
pub(crate) fn indent_step(ctx: &egui::Context) -> f32 {
|
||||
ctx.data(|d| d.get_temp::<f32>(egui::Id::new("left-indent-step"))).unwrap_or(INDENT_STEP)
|
||||
}
|
||||
|
||||
/// How far right a row at `indent` (in [`INDENT_STEP`]s) is drawn this frame.
|
||||
fn indent_x(ctx: &egui::Context, indent: f32) -> f32 {
|
||||
indent / INDENT_STEP * indent_step(ctx)
|
||||
}
|
||||
|
||||
/// [`indent_x`] for a row being laid out, which also notes how deep the tree goes, for next
|
||||
/// frame's step.
|
||||
fn drawn_indent(ui: &egui::Ui, indent: f32) -> f32 {
|
||||
ui.data_mut(|d| {
|
||||
let m = d.get_temp_mut_or_insert_with::<f32>(egui::Id::new("left-deepest-indent-next"), || 0.0);
|
||||
*m = m.max(indent);
|
||||
});
|
||||
indent_x(ui.ctx(), indent)
|
||||
}
|
||||
|
||||
/// The widest step (16, 14, 12, 10 or 8 pt) at which a row `deepest` deep (in [`INDENT_STEP`]s)
|
||||
/// keeps [`MIN_NAME_ROOM`] for its name and its count in a panel `panel_w` wide. In steps, so a
|
||||
/// row does not slide sideways at every point the panel is dragged; when even the narrowest step
|
||||
/// does not fit, the sidebar scrolls sideways as before.
|
||||
pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 {
|
||||
let levels = deepest / INDENT_STEP;
|
||||
if levels < 1.0 {
|
||||
return INDENT_STEP;
|
||||
}
|
||||
let room = panel_w - 42.0 - MIN_NAME_ROOM - 18.0 - COUNT_ROOM;
|
||||
[16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP)
|
||||
}
|
||||
|
||||
fn row(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
@@ -82,6 +129,7 @@ fn row_sensed(
|
||||
) -> egui::Response {
|
||||
let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let indent = drawn_indent(ui, indent);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense);
|
||||
if scroll_asked(ui, id) {
|
||||
ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none());
|
||||
@@ -153,6 +201,7 @@ fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, c
|
||||
}
|
||||
ui.allocate_space(r.size());
|
||||
register(ui.ctx(), format!("source:{id}"), r);
|
||||
let indent = drawn_indent(ui, indent);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter();
|
||||
let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x;
|
||||
@@ -178,7 +227,7 @@ fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option<f32>) -> f32
|
||||
/// its response: the caller flips the open state on `clicked()`.
|
||||
fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let c = pos2(row.rect.left() + 10.0 + indent, row.rect.center().y);
|
||||
let c = pos2(row.rect.left() + 10.0 + indent_x(ui.ctx(), indent), row.rect.center().y);
|
||||
let tri = Rect::from_center_size(c, vec2(16.0, 16.0));
|
||||
let tr = ui.interact(tri, id, Sense::click());
|
||||
register(ui.ctx(), widget, tri);
|
||||
@@ -626,6 +675,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.set_max_width(wide);
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(egui::Id::new("left-content-width-next"), 0.0f32);
|
||||
d.insert_temp(egui::Id::new("left-deepest-indent-next"), 0.0f32);
|
||||
// where the visible part of the content ends (screen x), for what stays at the edge
|
||||
d.insert_temp(egui::Id::new("left-visible-right"), ui.cursor().left() + viewport.max.x);
|
||||
});
|
||||
@@ -713,6 +763,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-content-width"), next));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
// …and the deepest row drawn (or passed over out of sight) sets next frame's step
|
||||
let deepest = ui.data(|d| d.get_temp::<f32>(egui::Id::new("left-deepest-indent-next"))).unwrap_or(0.0);
|
||||
let step = step_for(viewport.width(), deepest);
|
||||
if step != indent_step(ui.ctx()) {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-indent-step"), step));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -738,10 +795,10 @@ fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) {
|
||||
for (m, n) in &g.months {
|
||||
let label = crate::i18n::date_group_label(m, true);
|
||||
if date_row(app, ui, m, &label, *n, 16.0) {
|
||||
if date_row(app, ui, m, &label, *n, INDENT_STEP) {
|
||||
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
|
||||
let label = crate::i18n::date_group_label(d, true);
|
||||
date_row(app, ui, d, &label, *n, 32.0);
|
||||
date_row(app, ui, d, &label, *n, 2.0 * INDENT_STEP);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1142,9 +1199,9 @@ fn folder_tree(
|
||||
synchronize_item(app, ui, &held.0, &held.1);
|
||||
}
|
||||
});
|
||||
if open && indent < 16.0 * 8.0 {
|
||||
if open && indent < INDENT_STEP * 8.0 {
|
||||
for (n, p) in subfolders(ui, path) {
|
||||
folder_tree(app, ui, place, &n, &p, indent + 16.0, current, reveal, false);
|
||||
folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1181,7 +1238,7 @@ type AlbumKids = std::collections::HashMap<Option<AlbumId>, Vec<Album>>;
|
||||
/// `open_to`: folders forced open this frame (the way down to an album that was just made).
|
||||
fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option<AlbumId>, indent: f32, open_to: &[AlbumId]) {
|
||||
// 64 folders deep at most (16 points a level): a catalog that loops must not overflow the stack
|
||||
if indent > 64.0 * 16.0 {
|
||||
if indent > 64.0 * INDENT_STEP {
|
||||
return;
|
||||
}
|
||||
let Some(kids) = all.get(&parent) else { return };
|
||||
@@ -1218,7 +1275,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare
|
||||
}
|
||||
folder_menu(app, &resp, a);
|
||||
if draw_open {
|
||||
albums_tree(app, ui, all, Some(a.id), indent + 16.0, open_to);
|
||||
albums_tree(app, ui, all, Some(a.id), indent + INDENT_STEP, open_to);
|
||||
}
|
||||
} else {
|
||||
let sel = app.session.source == LibrarySource::Album(a.id);
|
||||
@@ -1470,7 +1527,7 @@ fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Resp
|
||||
}
|
||||
Some(AlbumDrop::Place { parent, before, above }) => {
|
||||
let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 };
|
||||
let (x0, x1) = (resp.rect.left() + 18.0 + indent, ui.clip_rect().right() - 8.0);
|
||||
let (x0, x1) = (resp.rect.left() + 18.0 + indent_x(ui.ctx(), indent), ui.clip_rect().right() - 8.0);
|
||||
ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent));
|
||||
ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent));
|
||||
if released {
|
||||
@@ -1777,7 +1834,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode],
|
||||
folder_row(app, ui, n, &id, &name, indent, open_id, &mut open);
|
||||
}
|
||||
if open && !n.children.is_empty() {
|
||||
folder_rows(app, ui, &n.children, indent + 16.0);
|
||||
folder_rows(app, ui, &n.children, indent + INDENT_STEP);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2128,7 +2185,7 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode
|
||||
keyword_row(app, ui, n, &id, indent, open_id, &mut open);
|
||||
}
|
||||
if open {
|
||||
keyword_rows(app, ui, &n.children, indent + 16.0);
|
||||
keyword_rows(app, ui, &n.children, indent + INDENT_STEP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1795,21 +1795,30 @@ fn removing_a_disk_from_the_library_asks_first() {
|
||||
assert_eq!(h.app.session.visible().len(), 1, "everything on that disk left the library");
|
||||
}
|
||||
|
||||
/// However deep the folders go, each level is indented one step more than its parent, and the
|
||||
/// sidebar scrolls sideways to reach them instead of squeezing the nesting.
|
||||
/// The x of each folder's chevron down the chain `path` (`/a/b/c` → `/a`, `/a/b`, `/a/b/c`).
|
||||
fn chevron_xs(h: &Headless, path: &str) -> Vec<f32> {
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut at = String::new();
|
||||
for name in path.split('/').filter(|n| !n.is_empty()) {
|
||||
at.push('/');
|
||||
at.push_str(name);
|
||||
xs.push(widget(h, &format!("libraryFolderToggle:{at}")).center().x);
|
||||
}
|
||||
xs
|
||||
}
|
||||
|
||||
/// However deep the folders go, each level is indented one step more than its parent. The step
|
||||
/// narrows to make room, never below the narrowest, and past that the sidebar scrolls sideways
|
||||
/// instead of squeezing the nesting further.
|
||||
#[test]
|
||||
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";
|
||||
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 = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]);
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut path = String::new();
|
||||
for name in deep.split('/').filter(|n| !n.is_empty()) {
|
||||
path.push('/');
|
||||
path.push_str(name);
|
||||
xs.push(widget(&h, &format!("libraryFolderToggle:{path}")).center().x);
|
||||
}
|
||||
assert!(xs.len() == 13 && xs.windows(2).all(|w| (w[1] - w[0] - 16.0).abs() < 0.5), "every level steps right by one indent: {xs:?}");
|
||||
let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [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");
|
||||
assert!(xs.len() == 26 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "every level steps right by one indent: {xs:?}");
|
||||
let panel = widget(&h, "panel:left_panel");
|
||||
assert!(crate::panels::left::content_width(&h.view.ctx) > panel.width(), "the content is wider than the panel: the bar appears");
|
||||
assert!(widget(&h, "source:all").width() > panel.width(), "the rows are as wide as the widest one, so there is something to scroll to");
|
||||
@@ -1819,9 +1828,52 @@ fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() {
|
||||
}
|
||||
// what a row needs is known before it is drawn off screen, so the plus stays reachable
|
||||
assert!(widget(&h, "icon:albumNew").right() <= panel.right(), "Create Album stays inside the visible panel");
|
||||
// a click in the middle of a deep row chooses it; only the little triangle folds it
|
||||
click(&mut h, "source:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l");
|
||||
assert_eq!(h.app.session.library_folder.as_deref(), Some("/a/b/c/d/e/f/g/h/i/j/k/l"), "the row's middle is the row, not its triangle");
|
||||
// a click in the middle of a deep row chooses it; only the little chevron folds it
|
||||
let chosen = "/a/b/c/d/e/f/g/h/i/j/k/l";
|
||||
click(&mut h, &format!("source:libfolder:{chosen}"));
|
||||
assert_eq!(h.app.session.library_folder.as_deref(), Some(chosen), "the row's middle is the row, not its chevron");
|
||||
}
|
||||
|
||||
/// A library below a deep path (issue #2298): in the default panel the levels step right by
|
||||
/// less than a full step, so the deepest folder's name has room and nothing scrolls sideways;
|
||||
/// a wide panel goes back to full steps, and a shallow tree never narrows.
|
||||
#[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 = folders_app_sized(&[&format!("{deep}/a/1.jpg"), &format!("{deep}/b/2.jpg")], [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}");
|
||||
let xs = chevron_xs(&h, deep);
|
||||
assert!(xs.windows(2).all(|w| (w[1] - w[0] - narrow).abs() < 0.5), "every level still steps right by the same amount: {xs:?}");
|
||||
let panel = widget(&h, "panel:left_panel");
|
||||
assert!(crate::panels::left::content_width(&h.view.ctx) <= panel.width(), "nothing scrolls sideways");
|
||||
let id = format!("libfolder:{deep}");
|
||||
let (label, count) = (widget(&h, &format!("label:{id}")), widget(&h, &format!("count:{id}")));
|
||||
assert!(label.width() > 20.0 && label.right() + 4.0 <= count.left(), "the deepest name shows, left of its count: {label:?} {count:?}");
|
||||
// a wide panel has room for full steps again
|
||||
let r = h.request("ui.set", json!({"leftWidth": 480.0}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(crate::panels::left::indent_step(&h.view.ctx), full, "a wide panel indents by the full step");
|
||||
let xs = chevron_xs(&h, deep);
|
||||
assert!(xs.windows(2).all(|w| (w[1] - w[0] - full).abs() < 0.5), "{xs:?}");
|
||||
// a shallow tree keeps the full step in the default panel
|
||||
let h = folders_app(&["/pics/trip/a.jpg", "/pics/home/b.jpg"]);
|
||||
assert_eq!(crate::panels::left::indent_step(&h.view.ctx), full, "a shallow tree is drawn as before");
|
||||
}
|
||||
|
||||
/// The step is chosen in whole bands from the panel's width and the deepest row.
|
||||
#[test]
|
||||
fn the_indent_step_narrows_in_bands() {
|
||||
use crate::panels::left::step_for;
|
||||
// (room for the steps: the panel less the row's margins, a name's 80 pt and a count's 40)
|
||||
assert_eq!(step_for(268.0, 0.0), 16.0, "top-level rows only");
|
||||
assert_eq!(step_for(268.0, 5.0 * 16.0), 16.0, "five levels fit at full steps");
|
||||
assert_eq!(step_for(268.0, 7.0 * 16.0), 12.0, "seven levels at 12 pt");
|
||||
assert_eq!(step_for(268.0, 9.0 * 16.0), 8.0, "nine levels at 8 pt");
|
||||
assert_eq!(step_for(268.0, 30.0 * 16.0), 8.0, "never below 8 pt: the sidebar scrolls instead");
|
||||
assert_eq!(step_for(480.0, 9.0 * 16.0), 16.0, "a wide panel keeps full steps");
|
||||
}
|
||||
|
||||
/// A sidebar that fits needs no sideways scrolling.
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user