From f1e0a758d1048373247f6c3fdfacff681eac607d Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 14 Oct 2026 08:43:19 +0200 Subject: [PATCH 1/2] Home: file rows no longer draw the path over the name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Open files and Recent files rows painted the detail (the file's path) right-aligned at its full width and only then elided the name into whatever was left, with a floor of 40 px. A path wider than the row ran straight over the name, and the name itself shrank to "Mae…". The row is now laid out name first: the name keeps its full width when both fit, otherwise at least 60% of the row; the detail gets the room left after a 16 px gap and is cut at its start ("…/02-technik/KNX/ file.figcraft"), which keeps the file and its nearest folders. A detail with less than 48 px of room is left out. Rows of Recents' own lists show name and path in full on hover when either was cut. The new widgets::elide_start_font mirrors elide_font, keeping the end. --- crates/ui-egui/src/home.rs | 37 ++++++++++++++++++++++++++------ crates/ui-egui/src/home_tests.rs | 36 +++++++++++++++++++++++++++++-- crates/ui-egui/src/widgets.rs | 15 +++++++++++++ 3 files changed, 79 insertions(+), 9 deletions(-) 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 1179b1c4..953bfdeb 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 { @@ -222,6 +222,38 @@ 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 a_long_name_and_path_share_their_row_without_overlapping() { + let mut u = first_run(); + let name = "Maengelliste-Fenster-Grad-Schlupf-2026-10-15-mit-einem-langen-Rest.figcraft"; + let long = format!("/Users/someone/dev/Personal/haus/ProjektHaus/02-Haus/04-maengel/2026-10-15/{name}"); + let short = "/tmp/stage1.figcraft"; + u.app.session.prefs.recent_files = vec![long.clone(), short.into()]; + u.app.session.prefs.getting_started_dismissed = true; + let s = settle(&mut u); + // A row that fits shows its name and path in full. + assert!(has_text(&s, "stage1.figcraft") && 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 shown_name = all.iter().find(|(t, _, _)| t.starts_with("Ma")).expect("the name"); + let name_rect = rect(shown_name); + assert!(shown_name.0.starts_with("Maengelliste-Fenster") && shown_name.0.ends_with('…'), "{:?}", shown_name.0); + let detail = all + .iter() + .find(|e| e.0 != shown_name.0 && e.0.ends_with(".figcraft") && (rect(e).center().y - name_rect.center().y).abs() < 2.0) + .expect("the path on the name's row"); + let detail_rect = rect(detail); + 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(); From 639c4d7db0803619ab536a0688ce0e8d12875fb2 Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Wed, 14 Oct 2026 19:05:12 +0200 Subject: [PATCH 2/2] Home: test the file row layout on Open files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recent files are cards since #249, so the row test now opens two documents with synthetic paths and checks the Open files list: the row that fits shows name and path in full; the long one shows the name cut at its end, then the path cut at its start, at least 12 px apart, and both in full on hover. Without the row fix the name shows as "Quar…" under the path. --- crates/ui-egui/src/home_tests.rs | 34 ++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 953bfdeb..1025ad8e 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -223,27 +223,31 @@ fn a_first_run_offers_templates_on_recents_and_recent_files_replace_them() { } #[test] -fn a_long_name_and_path_share_their_row_without_overlapping() { - let mut u = first_run(); - let name = "Maengelliste-Fenster-Grad-Schlupf-2026-10-15-mit-einem-langen-Rest.figcraft"; - let long = format!("/Users/someone/dev/Personal/haus/ProjektHaus/02-Haus/04-maengel/2026-10-15/{name}"); - let short = "/tmp/stage1.figcraft"; - u.app.session.prefs.recent_files = vec![long.clone(), short.into()]; +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, "stage1.figcraft") && has_text(&s, short)); + 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 shown_name = all.iter().find(|(t, _, _)| t.starts_with("Ma")).expect("the name"); - let name_rect = rect(shown_name); - assert!(shown_name.0.starts_with("Maengelliste-Fenster") && shown_name.0.ends_with('…'), "{:?}", shown_name.0); - let detail = all - .iter() - .find(|e| e.0 != shown_name.0 && e.0.ends_with(".figcraft") && (rect(e).center().y - name_rect.center().y).abs() < 2.0) - .expect("the path on the name's row"); + 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. @@ -251,7 +255,7 @@ fn a_long_name_and_path_share_their_row_without_overlapping() { for _ in 0..60 { u.frame(vec![]); } - assert!(texts(&shapes(&mut u)).iter().any(|(t, _, _)| t.contains(&long)), "no tooltip with the full path"); + assert!(texts(&shapes(&mut u)).iter().any(|(t, _, _)| t.contains(long)), "no tooltip with the full path"); } #[test]