mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
Merge #324: fix(home): keep a long path from drawing over the file name in Open files (@patlux)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<char> = s.chars().collect();
|
||||
(1..chars.len())
|
||||
.rev()
|
||||
.map(|n| format!("…{}", chars[chars.len() - n..].iter().collect::<String>().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<usize>, cell: f32) -> Option<usize> {
|
||||
let names: Vec<&str> = items.iter().map(|(_, tip)| *tip).collect();
|
||||
|
||||
Reference in New Issue
Block a user