mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 22:06:49 -06:00
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user