M6: a long frame name stays on one line in the Export button and Dev Mode's header (#1650) (#1661)

This commit is contained in:
BFlat
2026-10-30 22:12:49 +07:00
committed by GitHub
parent db725dc73f
commit ddda495a6f
7 changed files with 172 additions and 20 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+134
View File
@@ -0,0 +1,134 @@
//! The Export section's button names the layer it exports on one line inside the button: a long
//! name ends with an ellipsis instead of wrapping into a block painted over the rest of the panel
//! (#1650), at the default panel width and wider.
use egui::{Rect, Shape};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
use crate::widgets::FIELD_H;
/// The text painted this frame, with each piece's box.
fn painted(u: &mut Ui) -> Vec<(String, Rect)> {
fn walk(shape: &Shape, out: &mut Vec<(String, Rect)>) {
match shape {
Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
_ => {}
}
}
u.frame(vec![]);
let frame = u.frame_output(vec![]);
let mut out = Vec::new();
for c in &frame.shapes {
walk(&c.shape, &mut out);
}
out
}
/// A frame named `name` with an export setting, selected, in a right panel `width` wide: the export
/// button's label and box, and the panel's box.
fn export_label(name: &str, width: f32) -> (String, Rect, Rect) {
let mut s = Session::new();
let f = crate::present_tests::id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 856, "height": 1089})).unwrap());
s.execute("export.add", &json!({"ids": [f]})).unwrap();
s.execute("node.set", &json!({"id": f, "props": {"name": name}})).unwrap();
s.execute("select.set", &json!({"ids": [f]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.right_width = width;
let texts = painted(&mut u);
let panel = u.app.ui.right_panel_rect.expect("the right panel shows");
assert!((panel.width() - width).abs() <= 1.0, "a {width} px panel (less its edge), not {panel:?}");
let buttons: Vec<(String, Rect)> = texts.into_iter().filter(|(t, r)| t.starts_with("Export ") && r.min.x >= panel.min.x).collect();
let [(text, rect)] = buttons.as_slice() else { panic!("one export button: {buttons:?}") };
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().name, name, "the name itself stays whole");
(text.clone(), *rect, panel)
}
#[test]
fn a_long_name_stays_on_one_line_inside_the_export_button() {
let long = "FRAME_NAME_OVERFLOW_".repeat(200);
for width in [240.0, 400.0] {
let (text, rect, panel) = export_label(&long, width);
assert!(text.ends_with('…'), "{width}: shortened, not {} characters", text.chars().count());
assert!(rect.min.x >= panel.min.x && rect.max.x <= panel.max.x, "{width}: {rect:?} inside {panel:?}");
assert!(rect.height() <= FIELD_H, "{width}: one line, not {} tall", rect.height());
}
}
/// Dev Mode's header names the layer on one line inside the panel too.
#[test]
fn dev_modes_header_shortens_a_long_name() {
let long = "FRAME_NAME_OVERFLOW_".repeat(200);
let mut s = Session::new();
let f = crate::present_tests::id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 856, "height": 1089})).unwrap());
s.execute("node.set", &json!({"id": f, "props": {"name": long}})).unwrap();
s.execute("select.set", &json!({"ids": [f]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.right_tab = crate::RightTab::Inspect;
let texts = painted(&mut u);
let panel = u.app.ui.right_panel_rect.expect("the right panel shows");
let names: Vec<(String, Rect)> = texts.into_iter().filter(|(t, r)| t.starts_with("FRAME_NAME") && r.min.x >= panel.min.x).collect();
assert!(!names.is_empty(), "the header names the layer");
for (text, rect) in names {
assert!(text.ends_with('…'), "shortened: {} characters", text.chars().count());
assert!(rect.max.x <= panel.max.x, "{rect:?} inside {panel:?}");
}
}
/// Eliding as it was done before: every candidate from the longest down, the first that fits.
fn elide_by_trying_all(ui: &egui::Ui, s: &str, max_w: f32, font: egui::FontId, from_start: bool) -> String {
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::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| {
if from_start {
format!("…{}", chars[chars.len() - n..].iter().collect::<String>().trim_start())
} else {
format!("{}…", chars.iter().take(n).collect::<String>().trim_end())
}
})
.find(|c| fits(c))
.unwrap_or_else(|| "…".into())
}
/// Eliding halves its way to the longest candidate that fits (a long name no longer lays out one
/// candidate per character every frame) and finds what trying every candidate finds.
#[test]
fn eliding_finds_what_trying_every_candidate_finds() {
use crate::theme::{regular, semibold};
let texts = [
"FRAME_NAME_OVERFLOW_".repeat(20),
"A name with spaces in it".to_string(),
"W".repeat(40),
"i".repeat(60),
"Ünïcödé names — with “quotes” and 漢字 too".to_string(),
];
let ctx = egui::Context::default();
let mut out = ctx.run_ui(Default::default(), |ui| {
for s in &texts {
for w in [0.0, 5.0, 12.0, 40.0, 97.5, 160.0, 400.0, 2000.0] {
for font in [regular(11.0), semibold(13.0)] {
let end = crate::widgets::elide_font(ui, s, w, font.clone());
assert_eq!(end, elide_by_trying_all(ui, s, w, font.clone(), false), "{s:?} in {w}");
let start = crate::widgets::elide_start_font(ui, s, w, font.clone());
assert_eq!(start, elide_by_trying_all(ui, s, w, font, true), "{s:?} from its start in {w}");
}
}
}
});
out.textures_delta.clear();
}
#[test]
fn line_breaks_in_a_name_show_as_spaces_and_short_names_in_full() {
let (text, rect, _) = export_label("First line\nsecond line", 240.0);
assert_eq!(text, "Export First line second line");
assert!(rect.height() <= FIELD_H);
assert_eq!(export_label("Frame 1", 240.0).0, "Export Frame 1");
}
+2
View File
@@ -98,6 +98,8 @@ mod display_name_tests;
#[cfg(test)]
mod duplicate_paint_tests;
#[cfg(test)]
mod export_name_tests;
#[cfg(test)]
mod export_preview_tests;
#[cfg(test)]
mod field_tests;
+5 -1
View File
@@ -362,8 +362,12 @@ pub(crate) fn wide_button(ui: &mut Ui, label: &str) -> bool {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
let galley = ui.painter().layout(label.to_string(), regular(11.0), t.text, w - 16.0);
// One line inside the button: a long layer name ends with an ellipsis and shows in full on
// hover (#1650).
let shown = widgets::elide(ui, &label.replace(['\r', '\n'], " "), w - 16.0);
let galley = ui.painter().layout_no_wrap(shown.clone(), regular(11.0), t.text);
ui.painter().galley(r.center() - galley.size() / 2.0, galley, t.text);
let resp = if shown == label { resp } else { resp.on_hover_text(label) };
clicked = resp.clicked();
});
clicked
+7 -2
View File
@@ -195,8 +195,13 @@ pub(super) fn inspect(app: &mut FigApp, ui: &mut Ui) {
crate::dev_mode::plugins_section(app, ui);
return;
};
let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default();
widgets::section_header(ui, &name, &[], false);
// The layer's name on one line: a long one ends with an ellipsis and shows in full on hover (#1650).
let name = doc.node(first).map(|n| n.name.replace(['\r', '\n'], " ")).unwrap_or_default();
let shown = widgets::elide_font(ui, &name, ui.available_width() - PAD - widgets::PAD_RIGHT, semibold(11.0));
let (_, header) = widgets::section_header_resp(ui, &shown, &[], false);
if shown != name {
header.on_hover_text(name);
}
crate::dev_mode::status(app, ui, &doc, &sel);
crate::dev_mode::annotations_section(app, ui, &doc, first);
crate::dev_mode::dev_resources_section(app, ui, &doc, first);
+20 -15
View File
@@ -775,31 +775,36 @@ pub fn elide(ui: &Ui, s: &str, max_w: f32) -> String {
/// [`elide`] in `font`.
pub fn elide_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.iter().take(n).collect::<String>().trim_end()))
.find(|c| fits(c))
.unwrap_or_else(|| "…".into())
elide_with(ui, s, max_w, font, |chars, n| format!("{}…", chars.iter().take(n).collect::<String>().trim_end()))
}
/// [`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 {
elide_with(ui, s, max_w, font, |chars, n| format!("…{}", chars[chars.len() - n..].iter().collect::<String>().trim_start()))
}
/// `s` if it fits `max_w` in `font`, else the longest `shortened(chars, n)` (keeping `n` of its
/// characters, 0 < n < all) that fits, else "…". Keeping more characters never makes it narrower,
/// so the longest is found by halving: a name of thousands of characters (#1650) lays out about a
/// dozen candidates, not one per character, on every frame.
fn elide_with(ui: &Ui, s: &str, max_w: f32, font: egui::FontId, shortened: impl Fn(&[char], usize) -> String) -> 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())
// Keeping `fit` characters fits (0: nothing kept yet); keeping `over` doesn't (all of them).
let (mut fit, mut over) = (0, chars.len());
while over - fit > 1 {
let mid = fit + (over - fit) / 2;
if fits(&shortened(&chars, mid)) {
fit = mid;
} else {
over = mid;
}
}
if fit == 0 { "…".into() } else { shortened(&chars, fit) }
}
/// A row of icon buttons; returns the index clicked.
+2 -1
View File
File diff suppressed because one or more lines are too long