diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 9b57cb0a..e6c78947 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -88,26 +88,49 @@ pub(crate) struct FileRow<'a> { pub detail_ink: Color32, } -/// Paint `row` in `r` (highlighted while `resp` is hovered): the name gives way to the detail. -pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) { +/// Paint `row` in `r` (highlighted while `resp` is hovered): the name first, the detail in the +/// room left after it, cut at its start; true when either is cut. +pub(crate) fn file_row(ui: &Ui, t: &Tokens, r: Rect, resp: &Response, row: &FileRow) -> bool { + /// Between the name and the detail. + const GAP: f32 = 16.0; + /// A detail with less room than this is left out rather than shown as a bare ellipsis. + const MIN_DETAIL: f32 = 48.0; if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } if let Some((icon, tint)) = row.mark { icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, tint); } - let detail = ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, row.detail, regular(row.detail_size), row.detail_ink); - let name = widgets::elide_font(ui, row.name, (detail.min.x - r.min.x - 44.0).max(40.0), regular(12.0)); - ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), row.ink); + let (name_font, detail_font) = (regular(12.0), regular(row.detail_size)); + let width = |s: &str, font: &egui::FontId| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x; + let room = (r.width() - 40.0).max(0.0); + let name_w = width(row.name, &name_font); + let detail_w = width(row.detail, &detail_font); + // When both don't fit, the name keeps at least 60% of the row and the detail gets the rest. + let name_max = if name_w + GAP + detail_w <= room { name_w } else { name_w.min((room - GAP - detail_w).max(room * 0.6)) }; + let name = widgets::elide_font(ui, row.name, name_max, name_font.clone()); + let detail_room = room - width(&name, &name_font) - GAP; + let detail = if row.detail.is_empty() || detail_room < MIN_DETAIL { + String::new() + } else { + widgets::elide_start_font(ui, row.detail, detail_room, detail_font.clone()) + }; + let cut = name != row.name || detail != row.detail; + ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, name, name_font, row.ink); + ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, detail, detail_font, row.detail_ink); + cut } /// A row of Recents' own lists: laid out in place, named for screen readers, ringed with the /// keyboard on it; true when chosen. fn listed(ui: &mut Ui, t: &Tokens, id: Id, height: f32, row: &FileRow) -> bool { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().min(640.0), height), Sense::hover()); - let resp = ui.interact(r, id, Sense::click()); + let mut resp = ui.interact(r, id, Sense::click()); a11y::button(&resp, row.name); - file_row(ui, t, r, &resp, row); + if file_row(ui, t, r, &resp, row) { + // Cut to fit: in full on hover. + resp = resp.on_hover_text(format!("{}\n{}", row.name, row.detail)); + } focus_ring(ui, t, r, &resp, 5); resp.clicked() } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index f2091f01..af651992 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -4,14 +4,14 @@ //! started card (closed, kept closed, and brought back from the Help menu). use egui::accesskit::Role; -use egui::{Event, Key, Modifiers, PointerButton, Pos2, Shape}; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape}; use figcraft_engine::Session; use figcraft_engine::templates::TEMPLATES; use serde_json::json; use crate::home::{Section, cached_thumbnails}; use crate::layers_drag_tests::Ui; -use crate::live_tests::{flat, has_text, text_center}; +use crate::live_tests::{flat, has_text, text_center, texts}; /// The app as a first run finds it: no documents, no recent files. fn first_run() -> Ui { @@ -233,6 +233,42 @@ fn a_first_run_offers_templates_on_recents_and_recent_files_replace_them() { assert!(has_text(&s, "Open files"), "the document opened from the template is listed"); } +#[test] +fn an_open_files_long_name_and_path_share_their_row_without_overlapping() { + let name = "Quarterly planning: onboarding flow review with annotations and handoff notes"; + let long = "/Users/alex/Documents/Clients/Northwind Traders/2026 Website relaunch/Design reviews/Week 42/onboarding-flow-review.figcraft"; + let short = "/tmp/wireframes.figcraft"; + let mut s = Session::new(); + s.execute("file.rename", &json!({"name": name})).unwrap(); + s.docs[0].path = Some(long.into()); + s.execute("file.new", &json!({"name": "Wireframes"})).unwrap(); + s.docs[1].path = Some(short.into()); + let mut u = Ui::new(s); + u.app.ui.home = true; + u.app.session.prefs.getting_started_dismissed = true; + let s = settle(&mut u); + assert!(has_text(&s, "Open files")); + // A row that fits shows its name and path in full. + assert!(has_text(&s, "Wireframes") && has_text(&s, short)); + // One that doesn't: the name, then the end of its path, side by side. + let all = texts(&s); + let rect = |(_, p, size): &(String, Pos2, egui::Vec2)| Rect::from_min_size(*p, *size); + let detail = all.iter().find(|(t, _, _)| t.ends_with("/onboarding-flow-review.figcraft")).expect("the path"); + let detail_rect = rect(detail); + let shown_name = + all.iter().find(|e| e.0.starts_with('Q') && (rect(e).center().y - detail_rect.center().y).abs() < 2.0).expect("the name on the path's row"); + let name_rect = rect(shown_name); + assert!(shown_name.0.starts_with("Quarterly planning: onboarding") && shown_name.0.ends_with('…'), "{:?}", shown_name.0); + assert!(name_rect.max.x + 12.0 <= detail_rect.min.x, "{:?} {name_rect:?} overlaps {:?} {detail_rect:?}", shown_name.0, detail.0); + assert!(detail.0.starts_with('…') && long.ends_with(detail.0.trim_start_matches('…')), "{:?}", detail.0); + // Hovered, the row shows them in full. + u.frame(vec![Event::PointerMoved(name_rect.center())]); + for _ in 0..60 { + u.frame(vec![]); + } + assert!(texts(&shapes(&mut u)).iter().any(|(t, _, _)| t.contains(long)), "no tooltip with the full path"); +} + #[test] fn the_getting_started_card_closes_stays_closed_and_comes_back_from_help() { let mut u = first_run(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 508e6fc4..db696a1e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -625,6 +625,21 @@ pub fn elide_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String { .unwrap_or_else(|| "…".into()) } +/// [`elide_font`] cut at its start, keeping its end: a path keeps its file and the folders +/// nearest it. +pub fn elide_start_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String { + let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w; + if fits(s) { + return s.to_string(); + } + let chars: Vec = s.chars().collect(); + (1..chars.len()) + .rev() + .map(|n| format!("…{}", chars[chars.len() - n..].iter().collect::().trim_start())) + .find(|c| fits(c)) + .unwrap_or_else(|| "…".into()) +} + /// A row of icon buttons; returns the index clicked. pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option, cell: f32) -> Option { let names: Vec<&str> = items.iter().map(|(_, tip)| *tip).collect();