From b9f951998fc196363607c64d61e379b526f43581 Mon Sep 17 00:00:00 2001 From: Elliot Graham Date: Fri, 9 Oct 2026 12:08:11 +0100 Subject: [PATCH] #491: Keep Local import headers within the centre pane Co-Authored-By: Codex --- crates/ui-egui/src/headless.rs | 8 ++ crates/ui-egui/src/panels/grid.rs | 129 +++++++++++------- .../ui-egui/src/panels/grid_header_tests.rs | 71 ++++++++++ 3 files changed, 161 insertions(+), 47 deletions(-) create mode 100644 crates/ui-egui/src/panels/grid_header_tests.rs diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d1fae1e1..e6929269 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -1663,6 +1663,14 @@ mod tests { assert_eq!(h.request("ui.clickWidget", json!({"id": "button:addToLibrary"}), t)["ok"], true); h.settle(SETTLE); assert_eq!(h.app.session.catalog.photos().filter(|p| !p.local).count(), library_before + 2); + // Truncated ancestors remain clickable and still open the original, unabridged path. + let inner = dir.join("inner"); + h.request("engine.execute", json!({"command": "library.browse", "params": {"path": inner.to_string_lossy()}}), t); + h.settle(SETTLE); + let parent_index = dir.to_string_lossy().split(['/', '\\']).filter(|p| !p.is_empty()).count() - 1; + assert_eq!(h.request("ui.clickWidget", json!({"id": format!("crumb:{parent_index}")}), t)["ok"], true); + h.settle(SETTLE); + assert_eq!(h.app.session.browse.as_ref().unwrap().path, dir.to_string_lossy().replace('\\', "/")); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 579be235..1b6644e0 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1,5 +1,9 @@ //! Photo Grid (justified rows) and Square Grid. Virtualized: only visible cells request thumbnails. +#[cfg(test)] +#[path = "grid_header_tests.rs"] +mod header_tests; + use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -137,8 +141,6 @@ 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 (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); - ui.painter().rect_filled(hr, 0.0, t.canvas); let sel_n = app.session.selection.ids.len(); let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog); let total = app.session.source_total(); @@ -147,9 +149,11 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) { Some(b) => { let local = app.caches.grid.local(&app.session.catalog, &ids, generation); - folder_header(app, ui, hr, &b, &ids, local, &cnt) + 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 @@ -665,55 +669,86 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square /// 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, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], local_n: usize, cnt: &str) { +fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, b: &lightcraft_engine::Browse, ids: &[PhotoId], local_n: usize, cnt: &str) -> Rect { let t = Tokens::get(ui.ctx()); - let mut child = - ui.new_child(egui::UiBuilder::new().max_rect(hr.shrink2(vec2(16.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); - child.spacing_mut().item_spacing.x = 4.0; - let parts: Vec<&str> = b.path.split(['/', '\\']).filter(|p| !p.is_empty()).collect(); - // the last few parts (the root side is elided) - let skip = parts.len().saturating_sub(4); - if skip > 0 { - child.label(egui::RichText::new("… ›").color(t.text_dim)); - } - for (i, part) in parts.iter().enumerate().skip(skip) { - let last = i + 1 == parts.len(); - let text = egui::RichText::new(*part).size(13.0).color(if last { t.text } else { t.text_label }); - let r = child.add(egui::Label::new(if last { text.strong() } else { text }).sense(Sense::click())); - register(child.ctx(), format!("crumb:{i}"), r.rect); - if !last { - if r.hovered() { - child.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + // The path/count and actions need independent rows. The frame reserves the actual height, + // including any wrapped actions, so the grid below never paints under the header. + let header = egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin::symmetric(16, 6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + let count_w = + ui.painter().layout_no_wrap(cnt.to_owned(), t.font(12.5), t.text_dim).size().x.ceil().min((ui.available_width() * 0.45).floor()); + let path_w = (ui.available_width() - count_w - 8.0).max(0.0).floor(); + ui.allocate_ui_with_layout(vec2(path_w, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + folder_breadcrumbs(app, ui, &b.path); + }); + let count = ui.add_sized(vec2(count_w, 24.0), egui::Label::new(egui::RichText::new(cnt).size(12.5).color(t.text_dim)).truncate()); + register(ui.ctx(), "grid:count", count.rect); + count.on_hover_text(cnt); + }); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 12.0; + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); + let mut sub = b.subfolders; + let c = ui.checkbox(&mut sub, crate::i18n::tr("Include subfolders")); + register(ui.ctx(), "check:includeSubfolders", c.rect); + if c.changed() { + let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub})); } - if r.clicked() { - let prefix = if b.path.starts_with('/') { format!("/{}", parts[..=i].join("/")) } else { parts[..=i].join("/") }; - let _ = app.run("library.browse", json!({"path": prefix})); - } - child.label(egui::RichText::new("›").color(t.text_dim)); - } - } - child.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(egui::RichText::new(cnt).size(12.5).color(t.text_dim)); - ui.add_space(10.0); - // counted once per view (cached); the ids are only gathered on a click - if local_n > 0 { - let label = crate::i18n::tr_format!("Add {local_n} to My Photos", local_n = local_n); - if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() { - let local: Vec = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect(); - let n = local.len(); - match app.run("photo.addToLibrary", json!({"ids": local})) { - Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" }, n = n)), - Err(e) => app.toast(ui.ctx(), e), + // Counted once per view (cached); the ids are only gathered on a click. + if local_n > 0 { + let label = crate::i18n::tr_format!("Add {local_n} to My Photos", local_n = local_n); + let button = + ui.add(egui::Button::new(egui::RichText::new(label).font(t.semibold(11.5)).color(t.text)).wrap().min_size(vec2(37.0, 24.0))); + register(ui.ctx(), "button:addToLibrary", button.rect); + if button.clicked() { + let local: Vec = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect(); + let n = local.len(); + match app.run("photo.addToLibrary", json!({"ids": local})) { + Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" }, n = n)), + Err(e) => app.toast(ui.ctx(), e), + } } } - } - let mut sub = b.subfolders; - let c = ui.checkbox(&mut sub, crate::i18n::tr("Include subfolders")); - register(ui.ctx(), "check:includeSubfolders", c.rect); - if c.changed() { - let _ = app.run("library.browse", json!({"path": b.path, "subfolders": sub})); - } + }); }); + register(ui.ctx(), "grid:folderHeader", header.response.rect); + header.response.rect +} + +fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { + let t = Tokens::get(ui.ctx()); + ui.spacing_mut().item_spacing.x = 4.0; + let parts: Vec<&str> = path.split(['/', '\\']).filter(|p| !p.is_empty()).collect(); + // With all sidebars open even four ellipses and their separators can exceed the row. + // Prefer the current folder, showing more ancestors as space permits. + let visible = ((ui.available_width() / 80.0) as usize).clamp(1, 4); + let skip = parts.len().saturating_sub(visible); + if skip > 0 { + ui.label(egui::RichText::new("… ›").color(t.text_dim)).on_hover_text(path); + } + let separator_w = ui.painter().layout_no_wrap("›".into(), t.font(13.0), t.text_dim).size().x + 8.0; + for (i, part) in parts.iter().enumerate().skip(skip) { + let remaining = parts.len() - i; + let last = remaining == 1; + // Share the bounded row among the remaining crumbs, leaving room for every separator. + let width = ((ui.available_width() - separator_w * (remaining - 1) as f32) / remaining as f32).max(0.0).floor(); + let text = egui::RichText::new(*part).size(13.0).color(if last { t.text } else { t.text_label }); + let r = ui.add_sized(vec2(width, 24.0), egui::Label::new(if last { text.strong() } else { text }).truncate().sense(Sense::click())); + register(ui.ctx(), format!("crumb:{i}"), r.rect); + if !last { + if r.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if r.clicked() { + let prefix = if path.starts_with('/') { format!("/{}", parts[..=i].join("/")) } else { parts[..=i].join("/") }; + let _ = app.run("library.browse", json!({"path": prefix})); + } + ui.label(egui::RichText::new("›").size(13.0).color(t.text_dim)); + } + r.on_hover_text(path); + } } /// While photos are dragged: a badge at the pointer; the drag ends when the button is up diff --git a/crates/ui-egui/src/panels/grid_header_tests.rs b/crates/ui-egui/src/panels/grid_header_tests.rs new file mode 100644 index 00000000..c55bffb5 --- /dev/null +++ b/crates/ui-egui/src/panels/grid_header_tests.rs @@ -0,0 +1,71 @@ +use egui::{Rect, vec2}; + +use crate::{LightcraftApp, Services, headless::HeadlessView, i18n::Locale}; + +fn text_bounds(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.visual_bounding_rect()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_bounds(s, out)), + _ => {} + } +} + +#[test] +fn local_folder_header_text_and_controls_do_not_overlap() { + for locale in [Locale::En, Locale::De, Locale::PtBr] { + for width in [136.0, 200.0, 320.0, 590.0, 1100.0] { + for scale in [1.0, 2.0] { + for (path, local_n) in [ + ("/Volumes/External drive/Photography archive/Trips and holidays/A very long folder name with many photographs to import", 2408), + ("/A very long folder name with many photographs to import", 2408), + (r"C:\Users\Photographer\Pictures\A very long folder name with many photographs to import", 2408), + ("/Pictures", 0), + ] { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let browse = lightcraft_engine::Browse { path: path.into(), subfolders: false }; + let ctx = egui::Context::default(); + crate::i18n::set_language(locale); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let counted = crate::i18n::tr_format!("{sel_n} selected · {counted}", sel_n = 2408, counted = "2408 photos"); + let mut texts = Vec::new(); + let mut widgets = Vec::new(); + let mut header = Rect::NOTHING; + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(width, 300.0), scale, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + header = super::folder_header(&mut app, ui, &browse, &[], local_n, &counted); + let (grid, _) = ui.allocate_exact_size(vec2(ui.available_width(), 100.0), egui::Sense::hover()); + assert!(grid.top() >= header.bottom(), "wrapped controls must reserve space above the grid"); + }); + }); + out.textures_delta.clear(); + texts.clear(); + for shape in &out.shapes { + text_bounds(&shape.shape, &mut texts); + } + widgets = crate::widgets::take_registry(&ctx); + } + crate::i18n::set_language(Locale::En); + assert!(header.left() >= 0.0 && header.right() <= width, "header must not expand past the centre pane: {header:?}"); + let check = widgets.iter().find(|(id, _)| id == "check:includeSubfolders").unwrap().1; + assert!(widgets.iter().any(|(id, _)| id.starts_with("crumb:"))); + assert_eq!(widgets.iter().any(|(id, _)| id == "button:addToLibrary"), local_n > 0); + for (id, rect) in &widgets { + assert!(header.contains_rect(*rect), "{locale:?} {width} {scale}: {id} outside header: {rect:?}"); + if id.starts_with("crumb:") { + assert!(!rect.intersects(check), "breadcrumb overlaps Include subfolders"); + } + } + for (i, a) in texts.iter().enumerate() { + assert!(header.expand(1.0).contains_rect(*a), "{locale:?} {width} {scale}: text outside header: {a:?}"); + for b in texts.iter().skip(i + 1) { + assert!(!a.intersects(*b), "{locale:?} {width} {scale}: painted text overlaps: {a:?} / {b:?}"); + } + } + } + } + } + } +}