Merge main into #2304

This commit is contained in:
bflatastic committed 2026-11-13 22:43:54 +07:00
commit 0263ec8a3a
42 files changed
+816 -72

No files matched your search

+78
View File
@@ -276,6 +276,31 @@ pub enum TextDirection {
/// Deepest list nesting (levels 0..=MAX_LIST_INDENT).
pub const MAX_LIST_INDENT: u8 = 8;
/// For each paragraph, whether it is an item where a list starts counting. Numbers count the items
/// of one list at one level and restart after a plain paragraph, a shallower item or the other
/// kind of list; the first item of such a run starts counting (and takes its own start number).
/// The layout numbers by this rule.
pub fn list_item_starts_counting(lists: &[ParaList]) -> Vec<bool> {
let mut run = [ListStyle::None; MAX_LIST_INDENT as usize + 1];
lists
.iter()
.map(|pl| {
if pl.list == ListStyle::None {
run.fill(ListStyle::None);
return false;
}
let d = usize::from(pl.indent).min(run.len() - 1);
for c in run.iter_mut().skip(d + 1) {
*c = ListStyle::None;
}
let Some(c) = run.get_mut(d) else { return false };
let starts = *c != pl.list;
*c = pl.list;
starts
})
.collect()
}
/// One paragraph's list settings and base direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
@@ -937,6 +962,24 @@ impl TextData {
self.fold_paragraph_lists();
}
/// Keep a start number only on the item where a numbered list starts counting, and give every
/// item that goes on counting 0 (the rule the layout numbers by, [`list_item_starts_counting`]).
/// An imported file may carry the list's start on every line; once the list is split, the
/// items after the split must restart at 1, not at the old start.
pub fn keep_list_starts_where_counting_starts(&mut self) {
if self.paragraphs.iter().all(|p| p.start_offset == 0) {
return;
}
self.fill_paragraphs();
let starts = list_item_starts_counting(&self.paragraphs);
for (pl, starts) in self.paragraphs.iter_mut().zip(starts) {
if !starts {
pl.start_offset = 0;
}
}
self.fold_paragraph_lists();
}
/// One style for all paragraphs with no nesting or start folds back into `list`.
fn fold_paragraph_lists(&mut self) {
if let Some(first) = self.paragraphs.first().copied()
@@ -1251,6 +1294,41 @@ mod case_tests {
t.clear_list_starts(0, 0);
assert_eq!(t.para_list(0).start_offset, 0);
}
#[test]
fn list_starts_stay_only_where_counting_starts() {
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let pl = |list, indent, start_offset| ParaList { list, indent, start_offset, ..ParaList::default() };
// (paragraphs, the starts kept, derivation)
let cases: Vec<(Vec<ParaList>, Vec<u32>, &str)> = vec![
(vec![pl(N, 0, 4), pl(N, 0, 4), pl(N, 0, 4)], vec![4, 0, 0], "items that go on counting lose theirs"),
(vec![pl(N, 0, 4), pl(P, 0, 0), pl(N, 0, 6)], vec![4, 0, 6], "a list after a plain paragraph keeps its own"),
(vec![pl(N, 0, 4), pl(B, 0, 0), pl(N, 0, 6)], vec![4, 0, 6], "and one after a bullet item"),
(
vec![pl(N, 0, 4), pl(N, 1, 2), pl(N, 1, 2), pl(N, 0, 4)],
vec![4, 2, 0, 0],
"a nested level keeps its first item's; the outer count goes on",
),
(vec![pl(N, 0, 1), pl(N, 1, 2), pl(N, 0, 9), pl(N, 1, 3)], vec![1, 2, 0, 3], "a shallower item starts the deeper count again"),
(vec![pl(N, 255, 4), pl(N, 9, 4), pl(N, 8, 4)], vec![4, 0, 0], "levels past the deepest count as the deepest"),
];
for (paragraphs, want, why) in cases {
let characters = vec!["x"; paragraphs.len()].join("\n");
let mut t = TextData { characters, paragraphs, ..TextData::new("") };
t.keep_list_starts_where_counting_starts();
let got: Vec<u32> = (0..t.paragraph_count()).map(|p| t.para_list(p).start_offset).collect();
assert_eq!(got, want, "{why}");
}
// No start anywhere: nothing is spelled out.
let mut t = TextData { list: N, ..TextData::new("a\nb") };
t.keep_list_starts_where_counting_starts();
assert!(t.paragraphs.is_empty());
// More entries than paragraphs, and an empty last paragraph.
let mut t = TextData { paragraphs: vec![pl(N, 0, 4); 5], ..TextData::new("a\n") };
t.keep_list_starts_where_counting_starts();
assert_eq!((t.para_list(0).start_offset, t.para_list(1).start_offset), (4, 0));
assert!(list_item_starts_counting(&[]).is_empty());
}
}
#[cfg(test)]
+59 -9
View File
@@ -88,7 +88,7 @@ fn a_sticky_is_a_square_frame_whose_note_grows_taller_as_it_fills() {
assert_eq!(n.fills[0].solid_color(), board_color("blue"));
assert!(n.layout.is_some() && !n.effects.is_empty());
assert_eq!(n.children.len(), 2, "the note and its author");
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 18.0);
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 16.0);
// A click on the note picks the sticky whole.
let doc = s.doc().unwrap();
assert_eq!(figcraft_doc::geometry::select_target(doc, note, &[], false), st);
@@ -99,19 +99,69 @@ fn a_sticky_is_a_square_frame_whose_note_grows_taller_as_it_fills() {
assert!(node(&s, st).width == 200.0 && node(&s, st).height >= 200.0);
// Undo puts the short note back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 18.0);
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 16.0);
}
#[test]
fn a_new_sticky_starts_at_18() {
fn a_new_sticky_is_small_16() {
let mut s = board();
for (cmd, p) in [("board.sticky", json!({"x": 0, "y": 0, "text": "Hi"})), ("board.sticky", json!({"x": 300, "y": 0}))] {
let note = id(&s.execute(cmd, &p).unwrap(), "text");
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 18.0);
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 16.0);
}
let next = id(&s.execute("board.nextSticky", &json!({})).unwrap(), "text");
assert_eq!(node(&s, next).kind.text().unwrap().style.font_size, 18.0);
assert_eq!(STICKY_TEXT_SIZE, 18.0);
assert_eq!(node(&s, next).kind.text().unwrap().style.font_size, 16.0);
assert_eq!(STICKY_TEXT_SIZE, 16.0);
}
#[test]
fn the_sticky_sizes_are_figjams() {
let table = [("Small", 16.0), ("Medium", 24.0), ("Large", 40.0), ("Extra large", 64.0), ("Huge", 96.0)];
assert_eq!(crate::board::STICKY_SIZES, table);
}
fn note_size(s: &Session, note: NodeId) -> f64 {
node(s, note).kind.text().unwrap().style.font_size
}
#[test]
fn set_style_font_size_sizes_every_selected_sticky() {
let mut s = board();
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "A"})).unwrap();
let b = s.execute("board.sticky", &json!({"x": 400, "y": 0, "text": "B"})).unwrap();
let ids = json!([id(&a, "id").0, id(&b, "id").0]);
s.execute("board.setStyle", &json!({"ids": ids, "fontSize": 24})).unwrap();
assert_eq!(note_size(&s, id(&a, "text")), 24.0);
assert_eq!(note_size(&s, id(&b, "text")), 24.0);
let bad = s.execute("board.setStyle", &json!({"ids": ids, "fontSize": 0}));
assert!(bad.is_err());
}
#[test]
fn the_sticky_tool_remembers_the_last_size() {
let mut s = board();
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "A"})).unwrap();
s.execute("board.setStyle", &json!({"ids": [id(&a, "id").0], "fontSize": 40})).unwrap();
let b = s.execute("board.sticky", &json!({"x": 400, "y": 0, "text": "B"})).unwrap();
assert_eq!(note_size(&s, id(&b, "text")), 40.0);
let c = s.execute("board.sticky", &json!({"x": 800, "y": 0, "fontSize": 64})).unwrap();
assert_eq!(note_size(&s, id(&c, "text")), 64.0);
assert!(s.execute("board.sticky", &json!({"x": 0, "y": 400, "fontSize": 5000})).is_err());
}
#[test]
fn a_huge_sticky_grows_taller_and_shrinks_back() {
figcraft_text::scan_system_fonts();
let mut s = board();
let r = s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Alpha"})).unwrap();
let st = id(&r, "id");
let ids = json!([st.0]);
let (w, h16) = (node(&s, st).width, node(&s, st).height);
s.execute("board.setStyle", &json!({"ids": ids, "fontSize": 96})).unwrap();
assert_eq!(node(&s, st).width, w, "the width stays");
assert!(node(&s, st).height > h16, "Huge is taller than Small");
s.execute("board.setStyle", &json!({"ids": ids, "fontSize": 40})).unwrap();
assert_eq!(node(&s, st).height, h16, "Large is square again");
}
#[test]
@@ -131,7 +181,7 @@ fn a_sticky_keeps_its_text_size_as_text_is_added() {
}
let ten = node(&s, note).kind.text().unwrap().clone();
assert_eq!(ten.characters.matches('\n').count(), 9, "ten lines");
assert_eq!(ten.style.font_size, 18.0);
assert_eq!(ten.style.font_size, 16.0);
assert!(node(&s, note).height > text_height, "ten lines make the text taller");
assert!(node(&s, st).height > height, "and the note");
assert_eq!(node(&s, st).child_layout.sizing_v, figcraft_doc::Sizing::Hug, "the note hugs its text");
@@ -140,8 +190,8 @@ fn a_sticky_keeps_its_text_size_as_text_is_added() {
crate::textedit::end(&mut s).unwrap();
let t = node(&s, note).kind.text().unwrap().clone();
assert!(t.characters.chars().count() >= 250);
assert_eq!(t.style.font_size, 18.0);
assert!(t.runs.iter().all(|r| r.style.font_size == 18.0));
assert_eq!(t.style.font_size, 16.0);
assert!(t.runs.iter().all(|r| r.style.font_size == 16.0));
assert_eq!(node(&s, st).width, w, "the note keeps its width");
assert!(node(&s, st).height > h10, "and grows taller");
}
+1 -1
View File
@@ -166,9 +166,9 @@ fn styles_labels_and_their_refusals() {
assert!(s.execute("board.label", &json!({"id": c, "text": ""})).unwrap()["label"].is_null());
assert!(!s.doc().unwrap().contains(l));
// What doesn't take a style or colour says so.
assert!(s.execute("board.setStyle", &json!({"ids": [a], "fontSize": 30})).is_err(), "stickies size their own text");
assert!(s.execute("board.setStyle", &json!({"ids": [c], "fontSize": 0})).is_err());
let st = id(&s.execute("board.stamp", &json!({"x": 0, "y": 0, "stamp": "check"})).unwrap(), "id");
assert!(s.execute("board.setStyle", &json!({"ids": [st], "fontSize": 30})).is_err(), "a stamp has no text to size");
assert!(s.execute("board.setColor", &json!({"ids": [st], "color": "blue"})).is_err());
assert!(s.execute("board.setColor", &json!({"ids": [a]})).is_err());
assert!(s.execute("board.label", &json!({"id": a, "text": "x"})).is_err());
+7 -2
View File
@@ -100,12 +100,16 @@ const HIGHLIGHTER: (f64, f32) = (20.0, 0.45);
const ERASER_PX: f64 = 8.0;
/// The text sizes the contextual bar offers.
pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)];
/// The text sizes a sticky's menu offers (FigJam's, measured 2026-11-12): name and size.
pub const STICKY_SIZES: [(&str, f64); 5] = [("Small", 16.0), ("Medium", 24.0), ("Large", 40.0), ("Extra large", 64.0), ("Huge", 96.0)];
/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place,
/// how new connectors run and the marker's and highlighter's colours.
#[derive(Clone, Debug)]
pub struct BoardTool {
pub sticky: Color,
/// The text size new stickies get (the last one chosen on a sticky).
pub sticky_size: f64,
pub shape: BoardShape,
pub stamp: Stamp,
pub routing: Routing,
@@ -118,6 +122,7 @@ impl Default for BoardTool {
let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1);
Self {
sticky: first,
sticky_size: STICKY_TEXT_SIZE,
shape: BoardShape::Rectangle,
stamp: Stamp::ThumbsUp,
routing: Routing::Elbow,
@@ -132,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"board.sticky",
"Sticky note",
"{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}",
"{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), fontSize? (1 to 1000; default the sticky tool's, 16 at first), author? (a name, or false for none; default yours), parent?} → {id, text}",
true,
sticky_cmd
),
@@ -217,7 +222,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"board.setStyle",
"Board style",
"{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}",
"{ids?, shape? (shapes with text), fontSize? (the text of stickies, shapes, labels and mind nodes; stickies remember it for new ones), style?: straight|elbow|curved, startCap?, endCap? (connectors)}",
true,
set_style
),
+12 -3
View File
@@ -130,9 +130,12 @@ impl Restyle {
BoardItem::Connector(ref c) => self.restyle_connector(doc, id, c.clone())?,
_ => false,
};
// Text sizes: shapes' and labels' text (stickies size theirs to fit).
// Text sizes: the text of stickies, shapes, labels and mind nodes (a sticky's note grows taller
// when its text no longer fits, by its auto layout).
let sized = match (self.size, &item) {
(Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::MindNode) => resize_text(doc, id, size),
(Some(size), BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::MindNode) => {
resize_text(doc, id, size)
}
_ => false,
};
Ok(changed || sized)
@@ -191,13 +194,19 @@ pub(super) fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
let restyle = Restyle::of(cmd, p)?;
let doc = s.doc_mut()?;
let mut changed = Vec::new();
let mut sticky_sized = false;
for (id, item) in items(doc, &ids) {
let sticky = matches!(item, BoardItem::Sticky { .. });
if restyle.apply(doc, id, item)? {
changed.push(id);
sticky_sized |= sticky && restyle.size.is_some();
}
}
if changed.is_empty() {
return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)"));
return Err(bad(cmd, "nothing selected takes these (stickies, shapes with text, connectors, labels)"));
}
if let (true, Some(size)) = (sticky_sized, restyle.size) {
s.tool.board.sticky_size = size;
}
Ok(json!({"ids": changed}))
}
+16 -10
View File
@@ -57,12 +57,13 @@ pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64
Ok(id)
}
/// The text size of a new sticky's note, FigJam's "Small" default (measured in the web app,
/// 2026-11-09): it never changes as the note fills, the note grows taller instead.
pub const STICKY_TEXT_SIZE: f64 = 18.0;
/// The text size of a new sticky's note, FigJam's "Small" default: 16, measured twice in the web
/// app on 2026-11-12 from the size field's value (an earlier reading of 18 did not reproduce). It
/// never changes as the note fills, the note grows taller instead.
pub const STICKY_TEXT_SIZE: f64 = 16.0;
/// A sticky note's layers: the frame, the note and (when known) its author.
fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> {
fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str, size: f64) -> Result<(NodeId, NodeId)> {
let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| {
n.fills = vec![Paint::solid(color)];
n.corners.radii = [2.0; 4];
@@ -80,7 +81,7 @@ fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, c
n.board = Some(BoardItem::Sticky { author: author.to_string() });
})?;
let inner = STICKY - 2.0 * STICKY_PAD;
let note = add_text(doc, id, text, STICKY_TEXT_SIZE, INK, TextAlignH::Left, inner)?;
let note = add_text(doc, id, text, size, INK, TextAlignH::Left, inner)?;
if !author.is_empty() {
let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?;
if let Some(n) = doc.node_mut(a) {
@@ -102,7 +103,12 @@ pub(super) fn sticky_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let parent = parent_of(cmd, s, p)?;
let text = string(p, "text").unwrap_or_default();
let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0));
let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?;
let size = match num(p, "fontSize") {
None => s.tool.board.sticky_size,
Some(v) if v.is_finite() && (1.0..=1000.0).contains(&v) => v,
Some(_) => return Err(bad(cmd, "`fontSize` is from 1 to 1000")),
};
let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author, size)?;
s.set_selection(vec![id]);
Ok(json!({"id": id, "text": note}))
}
@@ -155,11 +161,11 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result<Value> {
"above" => (0.0, -(STICKY + STICKY_GAP)),
other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))),
};
let author = author(s);
let (author, size) = (author(s), s.tool.board.sticky_size);
if editing.is_some() {
textedit::end(s)?;
}
let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?;
let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author, size)?;
s.set_selection(vec![id]);
if boolean(p, "edit") == Some(true) {
// Typing goes on in the new sticky once this step is recorded.
@@ -227,13 +233,13 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
}
None => (to_at, from_side, to_side),
};
let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?);
let (routing, author, page, size) = (s.tool.board.routing, author(s), s.page()?, s.tool.board.sticky_size);
if editing.is_some() {
textedit::end(s)?;
}
let doc = s.doc_mut()?;
let (id, text) = match shape {
None => make_sticky(doc, parent, to_at, "", color, &author)?,
None => make_sticky(doc, parent, to_at, "", color, &author, size)?,
Some(shape) => make_shape(doc, parent, to_at, next, shape, "", color)?,
};
let connector = Connector {
+119
View File
@@ -124,9 +124,62 @@ pub fn move_handles(net: &mut VectorNetwork, hs: &[(usize, bool)], d: Vec2, inde
}
}
/// Each handle of a sharp point turned smooth by a mirror mode is this fraction of the length of its
/// own segment. Measured in the incumbent (0.3905 and 0.390 at two angles): known to about 3 digits only.
const MIRROR_SMOOTH_FACTOR: f64 = 0.3905;
/// Give a sharp point (two segments, no handles) the handles a mirror mode makes in the incumbent.
/// Both lie on one line through the point, along `(B - A) / |BA|^2 + (C - B) / |BC|^2` (A, C: the far
/// ends of the two segments). "Angle": each is [`MIRROR_SMOOTH_FACTOR`] x its own segment.
/// "Angle and length": both are that factor x the shorter segment (medium confidence: the
/// measurement could not separate this from other rules). Open-path ends, points with a handle
/// already and coincident neighbours are left alone. Returns whether handles were made.
fn smooth_sharp_point(net: &mut VectorNetwork, vi: usize, mode: HandleMirroring) -> bool {
if mode == HandleMirroring::None {
return false;
}
let inc = net.incident(vi);
let [(s1, a1), (s2, a2)] = inc.as_slice() else { return false };
let (Some(h1), Some(h2)) = (net.handle(*s1, *a1), net.handle(*s2, *a2)) else { return false };
if tvec(h1).hypot() > 1e-9 || tvec(h2).hypot() > 1e-9 {
return false;
}
let far = |s: usize, st: bool| net.segments.get(s).and_then(|x| net.vertices.get(if st { x.b } else { x.a })).map(|v| v.point());
let (Some(b), Some(a), Some(c)) = (net.vertices.get(vi).map(|v| v.point()), far(*s1, *a1), far(*s2, *a2)) else { return false };
let (ab, bc) = (b - a, c - b);
let (la, lc) = (ab.hypot(), bc.hypot());
if !(la > 1e-9 && lc > 1e-9 && la.is_finite() && lc.is_finite()) {
return false;
}
let d = ab / (la * la) + bc / (lc * lc);
if !finite(d) || d.hypot() < 1e-12 {
return false;
}
let d = d.normalize();
let (l_in, l_out) = if mode == HandleMirroring::AngleAndLength {
let l = MIRROR_SMOOTH_FACTOR * la.min(lc);
(l, l)
} else {
(MIRROR_SMOOTH_FACTOR * la, MIRROR_SMOOTH_FACTOR * lc)
};
let (in_h, out_h) = (-d * l_in, d * l_out);
if !(finite(in_h) && finite(out_h)) {
return false;
}
net.set_handle(*s1, *a1, (in_h.x, in_h.y));
net.set_handle(*s2, *a2, (out_h.x, out_h.y));
true
}
/// Set the mirroring of vertices and make their handles agree with it.
pub fn set_mirroring(net: &mut VectorNetwork, verts: &[usize], mode: HandleMirroring) {
for &vi in verts {
if smooth_sharp_point(net, vi, mode) {
if let Some(v) = net.vertices.get_mut(vi) {
v.mirror = Some(mode);
}
continue;
}
let inc = net.incident(vi);
if let [(s1, a1), (s2, a2)] = inc.as_slice()
&& let (Some(h1), Some(h2)) = (net.handle(*s1, *a1), net.handle(*s2, *a2))
@@ -949,6 +1002,72 @@ mod tests {
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
}
/// An open path A, B, C (the measured triangle of #2110) with B sharp.
fn triangle_path() -> VectorNetwork {
VectorNetwork::from_points(&[Point::new(-250.0, -174.0), Point::new(117.0, -174.0), Point::new(-67.0, 194.0)], false)
}
fn handles_at_b(n: &VectorNetwork) -> (Vec2, Vec2) {
(tvec(n.segments[0].tb), tvec(n.segments[1].ta))
}
#[test]
fn mirror_modes_smooth_a_sharp_point_as_measured() {
// Mirror angle and length: both handles 0.3905 x the shorter segment (367), opposite.
let mut n = triangle_path();
set_mirroring(&mut n, &[1], HandleMirroring::AngleAndLength);
let (i, o) = handles_at_b(&n);
assert!((o - Vec2::new(86.24, 114.47)).hypot() < 0.1, "{o:?}");
assert!((i + o).hypot() < 1e-9, "{i:?} {o:?}");
assert!((o.hypot() - 143.3).abs() < 0.1, "{}", o.hypot());
// Mirror angle: each handle 0.3905 x its own segment; still collinear.
let mut n = triangle_path();
set_mirroring(&mut n, &[1], HandleMirroring::Angle);
let (i, o) = handles_at_b(&n);
assert!((i - Vec2::new(-86.24, -114.47)).hypot() < 0.1, "{i:?}");
assert!((o - Vec2::new(96.69, 128.33)).hypot() < 0.1, "{o:?}");
assert!((i.x * o.y - i.y * o.x).abs() < 1e-6, "collinear");
assert!((i.hypot() - 143.3).abs() < 0.1 && (o.hypot() - 160.7).abs() < 0.1);
assert_eq!(n.mirroring(1), HandleMirroring::Angle);
}
#[test]
fn mirror_modes_smooth_other_angles_and_several_points() {
// A right angle at B with legs 100 and 50: d = (1,0)/100 + (0,1)/50, so the line leans to +y.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0)], false);
set_mirroring(&mut n, &[1], HandleMirroring::Angle);
let (i, o) = handles_at_b(&n);
let want = Vec2::new(0.01, 0.02).normalize();
assert!((o.normalize() - want).hypot() < 1e-9, "{o:?}");
assert!((o.hypot() - 19.525).abs() < 1e-9 && (i.hypot() - 39.05).abs() < 1e-9, "{i:?} {o:?}");
// Two sharp points of a closed square, both selected: both become smooth.
let mut sq = square();
set_mirroring(&mut sq, &[0, 2], HandleMirroring::AngleAndLength);
for v in [0, 2] {
assert!(sq.incident(v).iter().all(|(s, st)| sq.handle(*s, *st).is_some_and(|h| tvec(h).hypot() > 1.0)), "vertex {v}");
assert_eq!(sq.mirroring(v), HandleMirroring::AngleAndLength);
}
assert!(sq.segments.iter().all(|s| tvec(s.ta).x.is_finite() && tvec(s.tb).y.is_finite()));
}
#[test]
fn mirror_modes_leave_unmeasured_points_alone() {
// Open-path ends have only one segment.
let mut n = open_line();
set_mirroring(&mut n, &[0, 2], HandleMirroring::Angle);
assert!(n.segments.iter().all(|s| s.is_straight()));
// A zero-length neighbour: stays sharp, finite.
let mut z = VectorNetwork::from_points(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0), Point::new(60.0, 0.0)], false);
set_mirroring(&mut z, &[1], HandleMirroring::AngleAndLength);
assert!(z.segments.iter().all(|s| s.is_straight()));
// One handle already: the old behaviour (the lone handle stays, the other mirrors it).
let mut h = open_line();
h.segments[1].ta = (10.0, 5.0);
set_mirroring(&mut h, &[1], HandleMirroring::Angle);
assert_eq!(h.segments[1].ta, (10.0, 5.0));
assert_eq!(h.segments[0].tb, (-10.0, -5.0));
}
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
let ends: Vec<(usize, usize)> = n
.loop_walk(lp)
@@ -407,6 +407,24 @@ fn mirroring_position_and_join_commands() {
assert!(s.execute("vector.join", &json!({"ids": [id]})).is_ok());
}
#[test]
fn mirror_mode_on_sharp_points_is_one_undo_step() {
// #2110: a mirror mode turns sharp points smooth; the whole command undoes in one step.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[-250, -174], [117, -174], [-67, 194]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.select", json!({"vertices": [1]}));
ex(&mut s, "vector.edit.mirroring", json!({"mode": "angleAndLength"}));
let n = net(&s, id);
let (i, o) = (n.segments[0].tb, n.segments[1].ta);
assert!((o.0 - 86.24).abs() < 0.1 && (o.1 - 114.47).abs() < 0.1, "{o:?}");
assert!((i.0 + o.0).abs() < 1e-9 && (i.1 + o.1).abs() < 1e-9, "{i:?}");
s.undo().unwrap();
let n = net(&s, id);
assert!(n.segments.iter().all(|x| x.is_straight()), "one undo restores the sharp point");
}
#[test]
fn a_point_dropped_on_another_merges_into_it() {
// #401: an open path's end dragged onto its start closes the path at that point.
+4 -1
View File
@@ -184,7 +184,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.duplicate";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let pos = doc.variables.variables.iter().position(|x| x.id == v || x.name == v).ok_or_else(|| bad(cmd, "no such variable"))?;
// An exact id first, then a name: an earlier variable named like another's id must not be the
// one copied.
let vars = &doc.variables.variables;
let pos = vars.iter().position(|x| x.id == v).or_else(|| vars.iter().position(|x| x.name == v)).ok_or_else(|| bad(cmd, "no such variable"))?;
let mut copy = doc.variables.variables.get(pos).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let taken = |n: &str| doc.variables.variables.iter().any(|x| x.collection == copy.collection && x.name == n);
let name = (1..1000)
+26
View File
@@ -482,3 +482,29 @@ fn a_many_line_paste_clears_every_created_start() {
assert_eq!(d.para_list(0).start_offset, 4, "the first item keeps its start");
assert!((1..d.paragraph_count()).all(|p| d.para_list(p).start_offset == 0 && d.para_list(p).list == N));
}
#[test]
fn a_list_back_from_a_fig_split_in_the_middle_restarts_at_one() {
// #2273's steps: type "5. a", Enter, "b", Enter, "c"; save a copy as .fig and open it;
// Backspace at the start of "b". The export writes the start on every line, as Figma does,
// and "c" must then read 1., not 5.
let mut s = Session::new();
three_typed_items(&mut s, "5. a");
crate::textedit::end(&mut s).unwrap();
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
let t = back.nodes().find(|n| n.kind.text().is_some()).map(|n| n.id).unwrap();
let mut s = Session::with_document(back);
let d = data(&s, t);
assert_eq!(lists(&s, t), [(N, 0); 3]);
assert_eq!(
(d.para_list(0).start_offset, d.para_list(1).start_offset, d.para_list(2).start_offset),
(4, 0, 0),
"only the first item carries the start"
);
begin(&mut s, t, Some(2), false).unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
let d = data(&s, t);
assert_eq!(lists(&s, t), [(N, 0), (P, 0), (N, 0)]);
assert_eq!((d.para_list(0).start_offset, d.para_list(2).start_offset), (4, 0), "c reads 1.");
}
@@ -47,6 +47,18 @@ fn rename_describe_scope_and_duplicate() {
assert_eq!(vars.get(&id).unwrap().scopes, brand.scopes);
}
#[test]
fn duplicate_by_id_copies_that_variable_even_when_an_earlier_one_is_named_like_its_id() {
let mut s = Session::new();
tokens(&mut s);
let brand = s.doc().unwrap().variables.get("brand").unwrap().id.clone();
// The first variable in the list takes the later one's id as its name.
ex(&mut s, "variable.update", json!({"variable": "space/sm", "name": brand.clone()}));
let id = ex(&mut s, "variable.duplicate", json!({"variable": brand.clone()}))["id"].as_str().unwrap().to_string();
let vars = &s.doc().unwrap().variables;
assert_eq!(vars.get(&id).unwrap().name, "brand copy");
}
#[test]
fn modes_and_collections_rename_and_delete() {
let mut s = Session::new();
@@ -215,9 +215,12 @@ fn corner_radius_mirroring_and_delete(s: &mut Session, a: NodeId, b: NodeId, atl
fn bend_select_all_pen_and_clicks(s: &mut Session, a: NodeId, b: NodeId) {
// The Bend tool (held with Cmd/Ctrl) bends the segment under the pointer, in its own layer.
// (The earlier mirroring step already turned the selected corners smooth, #2110.)
let a_before = net(s, a).segments;
let b_before = net(s, b).segments;
drag(s, (250.0, 100.0), (250.0, 130.0), CMD);
assert!(net(s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
assert!(net(s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
assert!(net(s, b).segments != b_before, "the bend lands on the layer under the pointer");
assert!(net(s, a).segments == a_before, "other layers keep their segments");
// Select all takes every edited layer's points.
let r = ex(s, "vector.edit.selectAll", json!({}));
assert_eq!(r["count"], json!(8), "silent: select all takes the points of every edited layer (pinned)");
+20 -2
View File
@@ -64,7 +64,7 @@ fn numbered_lists_keep_their_start() {
assert_eq!(starts(&doc, "From 1"), [(N, 0)]);
assert_eq!(starts(&doc, "From 2"), [(N, 1)], "shows 2.");
assert_eq!(starts(&doc, "First only"), [(N, 3), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 3), (N, 3)]);
assert_eq!(starts(&doc, "Every line"), [(N, 3), (N, 0), (N, 0)], "Figma writes the start on every line; only the first counts");
assert_eq!(starts(&doc, "Mixed"), [(P, 0), (N, 4), (B, 0)], "bullets have no number, so no start");
assert_eq!(starts(&doc, "Junk"), [(N, 0)], "a negative offset counts from 1");
}
@@ -89,5 +89,23 @@ fn the_export_writes_where_a_list_starts() {
// line or per list counts the same); bullets and plain lines carry none.
assert_eq!(offsets, [1.0, 1.0, 0.0, 5.0, 0.0]);
let (back, _) = import(&bytes, "x").unwrap();
assert_eq!(starts(&back, "List"), [(N, 1), (N, 1), (P, 0), (N, 5), (B, 0)]);
assert_eq!(starts(&back, "List"), [(N, 1), (N, 0), (P, 0), (N, 5), (B, 0)], "reads back as it went in");
}
#[test]
fn a_start_on_every_line_counts_only_where_the_list_starts() {
use ListStyle::{None as P, Numbered as N};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
text_node(2, "Three", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("ORDERED_LIST", 4, false), line("ORDERED_LIST", 4, false)]),
text_node(3, "Split", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("PLAIN", 4, false), line("ORDERED_LIST", 0, true)]),
text_node(4, "Two lists", "a\nb\nc", vec![line("ORDERED_LIST", 4, true), line("PLAIN", 4, false), line("ORDERED_LIST", 6, true)]),
];
let sc = Schema::parse(LIST_SCHEMA).unwrap();
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(starts(&doc, "Three"), [(N, 4), (N, 0), (N, 0)]);
assert_eq!(starts(&doc, "Split"), [(N, 4), (P, 0), (N, 0)]);
assert_eq!(starts(&doc, "Two lists"), [(N, 4), (P, 0), (N, 6)], "a second list keeps its own start");
}
+20 -1
View File
@@ -675,7 +675,11 @@ fn kitchen_sink() -> Value {
]},
{"id": "k9", "name": "Sec", "type": "SECTION", "sectionContentsHidden": true, "relativeTransform": at(0.0, 0.0), "size": {"x": 50, "y": 50},
"children": [{"id": "k10", "name": "Use", "type": "INSTANCE", "componentId": "3:1", "relativeTransform": at(0.0, 0.0), "size": {"x": 24, "y": 24},
"componentProperties": {"Show#4:1": {"type": "BOOLEAN", "value": false}}}]}
"componentProperties": {"Show#4:1": {"type": "BOOLEAN", "value": false}}}]},
{"id": "k11", "name": "Listed", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "characters": "a\nb\nc\nd\ne\n",
"style": {"fontFamily": "Inter", "fontSize": 12},
"lineTypes": ["ORDERED", "ORDERED", "ORDERED", "UNORDERED", "ORDERED", "ORDERED"], "lineIndentations": [0, 0, 1, 0, 300, 0],
"listStartOffsets": [4, 4, 3, 2, 7, 9]}
]);
if let (Some(kids), Some(more)) = (v.pointer_mut("/document/children/0/children").and_then(Value::as_array_mut), extra.as_array()) {
kids.extend(more.iter().cloned());
@@ -692,6 +696,9 @@ fn kitchen_sink_imports_cleanly() {
assert!(matches!(named(&doc, "Sec").kind, NodeKind::Section { contents_hidden: true }));
assert!(matches!(&named(&doc, "Use").kind, NodeKind::Instance { props, .. } if props.get("Show") == Some(&json!(false))));
assert!(matches!(named(&doc, "Txt").kind, NodeKind::Text(ref t) if t.list == ListStyle::Numbered && t.runs.len() == 3));
let NodeKind::Text(l) = &named(&doc, "Listed").kind else { panic!("not text") };
let got: Vec<(u8, u32)> = (0..l.paragraph_count()).map(|p| (l.para_list(p).indent, l.para_list(p).start_offset)).collect();
assert_eq!(got, [(0, 4), (0, 0), (1, 3), (0, 0), (8, 7), (0, 9)], "starts stay where a count starts; the level is capped");
assert!(warnings.iter().all(|w| !w.contains("panick")), "{warnings:?}");
}
@@ -801,3 +808,15 @@ fn an_arc_without_an_end_is_whole_wherever_it_starts() {
assert_eq!(sweep, std::f64::consts::TAU, "start {start}");
}
}
#[test]
fn a_start_on_every_line_counts_only_where_the_list_starts() {
let (doc, _) = one(json!([
{"id": "t1", "name": "Every", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10}, "characters": "a\nb\nc",
"style": {"fontFamily": "Inter", "fontSize": 12},
"lineTypes": ["ORDERED", "ORDERED", "ORDERED"], "listStartOffsets": [4, 4, 4]}
]));
let figcraft_doc::NodeKind::Text(t) = &named(&doc, "Every").kind else { panic!("not text") };
let offsets: Vec<u32> = (0..t.paragraph_count()).map(|p| t.para_list(p).start_offset).collect();
assert_eq!(offsets, [4, 0, 0]);
}
+3
View File
@@ -283,6 +283,9 @@ fn lists(v: &Value, td: &mut TextData) {
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
td.set_para_lists(0, td.char_count(), |_| {});
// Only the item where a numbered list starts counting keeps its start; Figma writes the start on
// every line of the list, and a list split later must restart at 1.
td.keep_list_starts_where_counting_starts();
}
/// The decoration's style, thickness, offset, skip ink and colour (`{value, unit}` lengths with
+10 -8
View File
@@ -1469,25 +1469,27 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec<String> {
// "Figma bullets will keep the same styling at all levels of indentation." (Create bulleted
// and numbered lists)
const BULLET: &str = "\u{2022}";
let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1];
let starts = figcraft_doc::list_item_starts_counting(lists);
let mut count = [0usize; figcraft_doc::MAX_LIST_INDENT as usize + 1];
lists
.iter()
.map(|pl| {
.zip(starts)
.map(|(pl, starts)| {
if pl.list == ListStyle::None {
count.fill((ListStyle::None, 0));
count.fill(0);
return String::new();
}
let d = usize::from(pl.indent).min(count.len() - 1);
for c in count.iter_mut().skip(d + 1) {
*c = (ListStyle::None, 0);
*c = 0;
}
let Some(c) = count.get_mut(d) else { return String::new() };
let first = usize::try_from(pl.start_offset).unwrap_or(usize::MAX).saturating_add(1);
*c = if c.0 == pl.list { (pl.list, c.1.saturating_add(1)) } else { (pl.list, first) };
*c = if starts { first } else { c.saturating_add(1) };
match (pl.list, d % 3) {
(ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)),
(ListStyle::Numbered, 2) => format!("{}.", roman(c.1)),
(ListStyle::Numbered, _) => format!("{}.", c.1),
(ListStyle::Numbered, 1) => format!("{}.", alpha(*c)),
(ListStyle::Numbered, 2) => format!("{}.", roman(*c)),
(ListStyle::Numbered, _) => format!("{}.", *c),
_ => BULLET.to_string(),
}
})
+6
View File
@@ -522,6 +522,7 @@ name = "English"
"Duo" = "Duo"
"Duplicate" = "Duplicate"
"Duplicate by dragging" = "Duplicate by dragging"
"Duplicate group" = "Duplicate group"
"Duplicate mode" = "Duplicate mode"
"Duplicate page" = "Duplicate page"
"Edit" = "Edit"
@@ -1578,6 +1579,11 @@ name = "English"
"Text content" = "Text content"
"Text on a path" = "Text on a path"
"Text size" = "Text size"
"Small" = "Small"
"Medium" = "Medium"
"Large" = "Large"
"Extra large" = "Extra large"
"Huge" = "Huge"
"Text style" = "Text style"
"Text styles" = "Text styles"
"Texts" = "Texts"
+26
View File
@@ -398,3 +398,29 @@ fn cmd_while_moving_a_connector_end_places_it_on_the_object() {
drag(&mut u, Modifiers::NONE);
assert!(matches!(end(&u), figcraft_doc::End::Node { at: None, side: figcraft_doc::Side::Right, .. }), "{:?}", end(&u));
}
#[test]
fn a_selected_sticky_has_a_size_menu_instead_of_the_size_cells() {
let mut u = board();
let r = u.app.session.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Alpha"})).unwrap();
let note = figcraft_engine::board::text_of(u.app.session.doc().unwrap(), id(&r)).unwrap();
u.frame(vec![]);
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "Small"), "the menu reads Small");
assert!(!has_text(&shapes, "XL"), "no S/M/L/XL cells");
// The menu sits after the 8 colours (208 from the bar's left edge).
let b = bar(&u).expect("the bar shows");
click(&mut u, pos2(b.min.x + 208.0 + 40.0, b.center().y));
let shapes = flat(&u.frame_output(vec![]).shapes);
// Each row is padded to line up with the check mark on the current one.
for name in ["✓ Small", " Medium", " Large", " Extra large", " Huge"] {
assert!(has_text(&shapes, name), "{name:?} in the menu");
}
click(&mut u, text_center(&shapes, " Large"));
let size = |u: &Ui| u.app.session.doc().unwrap().node(note).unwrap().kind.text().unwrap().style.font_size;
assert_eq!(size(&u), 40.0);
u.frame(vec![]);
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "Large") && !has_text(&shapes, "Small"), "the menu reads Large");
}
+56 -2
View File
@@ -4,7 +4,7 @@
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeId, NodeKind, Routing, Stamp, StrokeCap};
use figcraft_engine::board::{BoardTool, TEXT_SIZES};
use figcraft_engine::board::{BoardTool, STICKY_SIZES, TEXT_SIZES};
use figcraft_engine::board_grid::GridStyle;
use serde_json::{Value, json};
@@ -96,6 +96,9 @@ struct Has {
color: bool,
shape: Option<BoardShape>,
text: bool,
/// The first selected sticky's text size: stickies take a size menu (not the S/M/L/XL cells)
/// when they are the only text-bearing objects selected.
sticky: Option<f64>,
connector: Option<Line>,
/// Every selected board object is a connector: the colour part offers any colour too.
only_lines: bool,
@@ -121,7 +124,15 @@ fn has(app: &FigApp) -> Option<Has> {
for n in ds.selection.iter().filter_map(|id| ds.doc.node(*id)) {
others |= n.board.is_some() && !matches!(n.board, Some(BoardItem::Connector(_)));
match &n.board {
Some(BoardItem::Sticky { .. } | BoardItem::Ink) => h.color = true,
Some(BoardItem::Sticky { .. }) => {
h.color = true;
let size = figcraft_engine::board::text_of(&ds.doc, n.id)
.and_then(|t| ds.doc.node(t))
.and_then(|t| t.kind.text())
.map_or(figcraft_engine::board::STICKY_TEXT_SIZE, |t| t.style.font_size);
h.sticky.get_or_insert(size);
}
Some(BoardItem::Ink) => h.color = true,
Some(BoardItem::Shape { shape }) => {
h.color = true;
h.text = true;
@@ -154,6 +165,8 @@ const SWATCH: f32 = 20.0;
const GAP: f32 = 4.0;
/// The room each part takes after its controls (the divider and the gap around it).
const PART_END: f32 = 8.0;
/// The sticky size menu's width: its name ("Extra large") and the chevron.
const STICKY_W: f32 = 96.0;
impl Has {
/// The bar's width for these parts.
@@ -168,6 +181,9 @@ impl Has {
if self.shape.is_some() {
w += CELL + 12.0 + GAP + PART_END;
}
if self.sticky.is_some() && !self.text {
w += STICKY_W + PART_END;
}
if self.text {
w += TEXT_SIZES.len() as f32 * (CELL + 2.0) + PART_END;
}
@@ -226,6 +242,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
}
if h.text {
x = size_part(app, ui, x, cy, &t);
} else if let Some(size) = h.sticky {
x = sticky_size_part(app, ui, size, pos2(x, cy), &t);
}
if h.table {
x = crate::board_picker::table_part(app, ui, x, cy, &t);
@@ -338,6 +356,42 @@ fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) ->
divider(ui, t, x, cy)
}
/// A sticky size's name as the UI says it (`None` when no entry has that size).
fn sticky_size_label(size: f64) -> Option<&'static str> {
STICKY_SIZES.iter().find(|(_, s)| (*s - size).abs() < 0.01).map(|(name, _)| match *name {
"Small" => tr_noop("Small"),
"Medium" => tr_noop("Medium"),
"Large" => tr_noop("Large"),
"Extra large" => tr_noop("Extra large"),
_ => tr_noop("Huge"),
})
}
/// The sticky text size menu at `at` (its left edge and middle), showing `size` by name, or as a
/// number when no entry has it; `board.setStyle {fontSize}`; where the next part starts.
fn sticky_size_part(app: &mut FigApp, ui: &mut Ui, size: f64, at: Pos2, t: &Tokens) -> f32 {
let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(STICKY_W, CELL));
let tip = tr("Text size");
let resp = ui.interact(r, ui.scope_id().with("bb_sticky_size"), Sense::click()).on_hover_text(tip);
a11y::menu_button(&resp, tip, a11y::menu_open(&resp));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
let name = sticky_size_label(size).map_or_else(|| format!("{}", (size * 100.0).round() / 100.0), |l| tr(l).to_string());
ui.painter().text(pos2(r.min.x + 8.0, at.y), egui::Align2::LEFT_CENTER, name, semibold(11.0), t.text);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 9.0, at.y), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary);
crate::right::menu_popup(&resp).show(|ui| {
for (name, value) in STICKY_SIZES {
let label = sticky_size_label(value).unwrap_or(name);
if crate::right::menu_check(ui, (value - size).abs() < 0.01, tr(label), "") {
app.run("board.setStyle", json!({"fontSize": value}));
ui.close();
}
}
});
divider(ui, t, at.x + STICKY_W, at.y)
}
/// A start or end point menu's width: the end's picture and the menu's chevron.
const CAP_W: f32 = 62.0;
/// The line style menu's width.
@@ -574,6 +574,10 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
start_rename(st, target.clone());
ui.close();
}
if menu_item(ui, tr("Duplicate group"), "") {
duplicate_group(app, &members, &list, g);
ui.close();
}
if menu_item(ui, tr("Delete group"), "") {
let steps: Vec<serde_json::Value> = members
.iter()
@@ -614,6 +618,28 @@ fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st:
}
}
/// Copy group `g` (its variables `members`, every mode, scope and setting) as the group named
/// after it plus " 2" (the next free number among the collection's groups `list`), in one undo
/// step. Each copy is `variable.duplicate` then a rename; an alias keeps pointing at its target.
fn duplicate_group(app: &mut FigApp, members: &[&Variable], list: &[String], g: &str) {
let (parent, leaf) = g.rsplit_once('/').map_or(("", g), |(p, l)| (p, l));
let leaf = unique(leaf, |n| {
let full = if parent.is_empty() { n.to_string() } else { format!("{parent}/{n}") };
full == g || list.contains(&full) || members.iter().any(|v| v.name.starts_with(&format!("{full}/")))
});
let copy = if parent.is_empty() { leaf } else { format!("{parent}/{leaf}") };
let prefix = format!("{g}/");
let mut steps: Vec<serde_json::Value> = Vec::new();
for (k, v) in members.iter().filter(|v| v.name.starts_with(&prefix)).enumerate() {
let tag = format!("copy{k}");
steps.push(json!({"command": "variable.duplicate", "params": {"variable": v.id}, "as": tag}));
steps.push(
json!({"command": "variable.update", "params": {"variable": {"$ref": tag}, "name": format!("{copy}/{}", &v.name[prefix.len()..])}}),
);
}
app.run("command.batch", json!({"label": "Duplicate group", "commands": steps}));
}
fn create_collection(app: &mut FigApp, vars: &Variables, st: &mut Editor) {
let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n));
if let Some(id) =
+26 -4
View File
@@ -6,11 +6,13 @@ use egui::{Context, Rect, pos2, vec2};
use crate::icons::{self, Icon};
use crate::widgets::{icon_button_at, icon_glyph_rect, section_header};
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
/// The bounding box of every glyph-like shape painted in one frame of `add`, one per shape, with
/// the pointer at `pointer` (hover-only buttons) when given.
pub(crate) fn painted_rects(pointer: Option<egui::Pos2>, add: impl FnOnce(&mut egui::Ui)) -> Vec<Rect> {
let ctx = Context::default();
let mut add = Some(add);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() };
let events = pointer.map(egui::Event::PointerMoved).into_iter().collect();
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 600.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
if let Some(f) = add.take() {
f(ui);
@@ -21,7 +23,27 @@ fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
.iter()
.filter(|s| matches!(s.shape, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_)))
.map(|s| s.shape.visual_bounding_rect())
.fold(Rect::NOTHING, |a, b| a.union(b))
.collect()
}
/// Bounding box of everything painted in one frame of `add`.
fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect {
painted_rects(None, add).into_iter().fold(Rect::NOTHING, |a, b| a.union(b))
}
/// Whether `painted` holds `icon` drawn at the size of a 24 pt `icons::button`'s glyph (16 pt): the
/// reference glyph's shapes, moved by one offset, are all among `painted` (same sizes and spacing,
/// so a three-dot glyph's spread counts too).
pub(crate) fn paints_icon_at_button_size(painted: &[Rect], icon: Icon) -> bool {
let reference = painted_rects(None, |ui| {
icons::button(ui, icon, 24.0, false, "reference");
});
let same = |a: Rect, b: Rect| (a.min - b.min).length() < 0.5 && (a.size() - b.size()).length() < 0.5;
let Some(first) = reference.first() else { return false };
painted.iter().any(|p| {
let shift = p.min - first.min;
reference.iter().all(|r| painted.iter().any(|q| same(*q, r.translate(shift))))
})
}
/// The header's eye, `icon_button_at`'s and a fill row's (`icons::button`, the reporter's
+28 -2
View File
@@ -180,7 +180,7 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) {
if mresp.hovered() {
ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), more.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(more), Icon::Dots, t.icon);
let ctx = ui.ctx().clone();
let at = (f.server.as_str(), f.file.as_str());
crate::right::menu_popup(&mresp).width(240.0).show(|ui| {
@@ -302,7 +302,7 @@ fn team_row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, server: &str, web: &str,
if mresp.hovered() {
ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), more.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(more), Icon::Dots, t.icon);
crate::right::menu_popup(&mresp).width(240.0).show(|ui| {
if crate::right::menu_item(ui, tr("Open"), "") {
open_team(app, server, f);
@@ -427,3 +427,29 @@ pub fn inspect(app: &FigApp) -> Value {
"rename": app.ui.online.rename.as_ref().map(|r| &r.2),
})
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use figcraft_engine::figcraft_collab::message::Access;
/// The menu buttons of Online's file rows and team rows paint their dots at 16 pt (#2238).
#[test]
fn online_row_menus_paint_16pt() {
let t_app = || FigApp::new(figcraft_engine::Session::new(), Box::new(crate::NoServices));
let mut app = t_app();
let file = OnlineFile { server: "wss://share.example/".into(), file: "f".into(), name: "Trail".into(), ..OnlineFile::default() };
let rows = painted_rects(None, |ui| {
let t = Tokens::get(ui.ctx());
row(&mut app, ui, &t, &file, false);
});
assert!(paints_icon_at_button_size(&rows, Icon::Dots), "file row menu is not 16 pt");
let team = TeamFile { file: "f".into(), name: "Trail".into(), updated: 0, people: 0, access: Access::Edit };
let teams = painted_rects(None, |ui| {
let t = Tokens::get(ui.ctx());
team_row(&mut app, ui, &t, "wss://share.example/", "", &team);
});
assert!(paints_icon_at_button_size(&teams, Icon::Dots), "team row menu is not 16 pt");
}
}
@@ -0,0 +1,37 @@
//! Every 24 pt icon button paints its glyph at 16 pt, the size `icons::button` draws (#2238, after
//! the Design panel's in #2227). The panels' own buttons: the Layers panel's header, the Assets
//! panel's back button and the Dev Mode status button. The tests of the other panels sit beside
//! them (`present/tests.rs`, and the `icon_size_tests` modules of `versions`, `proto_panel`,
//! `online` and the style picker).
use egui::{Rect, pos2, vec2};
use figcraft_engine::Session;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
use crate::{FigApp, NoServices};
fn app() -> FigApp {
FigApp::new(Session::new(), Box::new(NoServices))
}
#[test]
fn layers_and_assets_icon_buttons_paint_16pt() {
let mut a = app();
let rail = painted_rects(None, |ui| crate::layers::left_panel(&mut a, ui));
for icon in [Icon::Panels, Icon::Search, Icon::Plus] {
assert!(paints_icon_at_button_size(&rail, icon), "Layers panel: {icon:?} is not 16 pt");
}
a.ui.libraries = true;
let back = painted_rects(None, |ui| crate::assets_panel::show(&mut a, ui));
assert!(paints_icon_at_button_size(&back, Icon::ChevronLeft), "Assets panel: the back chevron is not 16 pt");
}
#[test]
fn the_dev_mode_status_button_paints_a_16pt_glyph() {
let mut a = app();
let doc = a.session.doc().unwrap().clone();
let button = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
let painted = painted_rects(None, |ui| crate::section_panel::status_icon(&mut a, ui, &doc, &[], button));
assert!(paints_icon_at_button_size(&painted, Icon::Code), "the status button's code glyph is not 16 pt");
}
+2
View File
@@ -132,6 +132,8 @@ mod home_tests;
#[cfg(test)]
mod host_save_tests;
#[cfg(test)]
mod icon_button_size_tests;
#[cfg(test)]
mod idle_tests;
#[cfg(test)]
mod ime_tests;
+1 -1
View File
@@ -818,7 +818,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) {
if bresp.hovered() {
ui.painter().rect_filled(back, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), back.shrink(5.0), Icon::ChevronLeft, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(back), Icon::ChevronLeft, t.icon);
ui.painter().text(pos2(back.max.x + 6.0, hr.center().y), Align2::LEFT_CENTER, tr("Libraries"), semibold(12.0), t.text);
if bresp.clicked() {
app.ui.libraries = false;
+4 -4
View File
@@ -168,7 +168,7 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
if hresp.hovered() {
ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), hr.shrink(5.0), Icon::Panels, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(hr), Icon::Panels, t.icon);
if hresp.clicked() {
app.run("view.minimize", json!({}));
}
@@ -204,7 +204,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if sresp.hovered() || app.ui.page_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Search, t.icon);
let search_id = Id::unique("page_search_field");
if sresp.clicked() {
app.ui.page_search = !app.ui.page_search;
@@ -218,7 +218,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) {
if aresp.hovered() {
ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(ar), Icon::Plus, t.icon);
if aresp.clicked() {
app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid);
if !crate::menus::dev_mode_refuses(app, "page.add") {
@@ -1090,7 +1090,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
if sresp.hovered() || app.ui.layer_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Search, t.icon);
let search_id = Id::unique("layer_search_field");
if sresp.clicked() {
app.ui.layer_search = !app.ui.layer_search;
+19 -1
View File
@@ -374,7 +374,7 @@ fn icon_button(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool {
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), icon, t.icon);
resp.clicked()
}
@@ -1354,3 +1354,21 @@ fn scroll_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId],
});
}
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
/// The panel's 24 pt icon buttons (flow play, preview, remove) paint their glyphs at 16 pt (#2238).
#[test]
fn prototype_icon_buttons_paint_16pt() {
for icon in [Icon::Play, Icon::Preview, Icon::Minus] {
let painted = painted_rects(None, |ui| {
let rect = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0));
icon_button(ui, Id::unique("proto_icon_size"), rect, icon, "x");
});
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ pub fn status_icon(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]
Some((DevStatus::None, _)) | None => t.icon,
Some(_) => t.accent_text,
};
icons::paint(ui.painter(), rect.shrink(5.0), Icon::Code, color);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), Icon::Code, color);
menu_popup(&resp).width(160.0).show(|ui| status_menu(app, ui, ids, doc));
}
+24 -2
View File
@@ -441,7 +441,7 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<A
if more.hovered() || menu_open {
ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), Icon::Dots, t.icon);
}
let more = more.tip(tr("More"));
let mut menu = |ui: &mut Ui| {
@@ -496,7 +496,7 @@ fn branch_row(ui: &mut Ui, b: &BranchRow) -> Option<Action> {
if more.hovered() || menu_open {
ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), Icon::Dots, t.icon);
}
let more = more.tip(tr("More"));
let mut menu = |ui: &mut Ui| {
@@ -607,3 +607,25 @@ fn buttons(ui: &mut Ui, primary: &str, enabled: bool) -> Option<bool> {
})
.inner
}
#[cfg(test)]
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
/// The version and branch rows' More buttons (shown on hover) paint their dots at 16 pt (#2238).
#[test]
fn version_and_branch_menus_paint_16pt() {
let over = Some(pos2(20.0, 20.0));
let row = Row { id: "v".into(), name: "Draft".into(), description: String::new(), time: 0, auto: false };
let version = painted_rects(over, |ui| {
version_row(ui, &row, true, false);
});
assert!(paints_icon_at_button_size(&version, Icon::Dots), "version row menu is not 16 pt");
let branch = BranchRow { id: "b".into(), name: "Try".into(), time: 0, state: BranchState::Active };
let branches = painted_rects(over, |ui| {
branch_row(ui, &branch);
});
assert!(paints_icon_at_button_size(&branches, Icon::Dots), "branch row menu is not 16 pt");
}
}
+2 -2
View File
@@ -262,13 +262,13 @@ fn header_text(t: Tokens, dark: bool) -> Color32 {
if dark { t.menu_text } else { t.text }
}
/// The X: a 24-point button, its icon inset 6 (the colour picker's header).
/// The X: a 24-point button, its cross 16 points like every 24 pt icon button (the colour picker's header).
fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool {
let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close"));
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover });
}
icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon });
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(rect), Icon::Close, if dark { t.menu_text } else { t.icon });
resp.clicked()
}
+21
View File
@@ -194,3 +194,24 @@ fn a_caller_can_put_it_where_it_likes() {
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect);
}
mod icon_size_tests {
use super::*;
use crate::header_icon_size_tests::paints_icon_at_button_size;
use crate::icons::Icon;
/// The header's 24 pt X paints its cross at 16 pt, like every other 24 pt icon button (#2238).
#[test]
fn the_close_button_paints_a_16pt_cross() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_close_size", "Colour");
let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
let painted: Vec<Rect> = shapes
.iter()
.filter(|s| matches!(s, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_)))
.map(|s| s.visual_bounding_rect())
.collect();
assert!(paints_icon_at_button_size(&painted, Icon::Close), "the popover's close cross is not 16 pt");
}
}
+1 -1
View File
@@ -173,7 +173,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
resp = resp.tip(tr("Inspect the layer under the pointer: size, colours, typography"));
}
if chrome.pane {
icons::paint(ui.painter(), br.shrink(5.0), *icon, if on { t.accent_on } else { t.icon });
icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), *icon, if on { t.accent_on } else { t.icon });
} else {
ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), if on { t.accent_on } else { text });
}
+18
View File
@@ -436,3 +436,21 @@ fn smart_animate_frame_cost() {
let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n);
eprintln!("smart animate: {per:.2} ms per frame (80 layers, 400x800 px)");
}
/// The preview pane's 24 pt bar buttons paint their glyphs at 16 pt (#2238).
#[test]
fn pane_bar_buttons_paint_16pt() {
use super::layout::Chrome;
use super::player::player;
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
let (d, a, ..) = device_doc();
let mut p = player_on(d, a);
let pane = Chrome { pane: true, bar_h: 32.0, pad: 12.0, round: 8, keys: false };
let painted = painted_rects(None, |ui| {
player(&mut p, ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 500.0)), 0.0, pane);
});
for icon in [Icon::Close, Icon::Play, Icon::Reset] {
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}
@@ -251,6 +251,23 @@ fn the_bulk_menu_scrolls_when_forty_groups_make_it_taller_than_the_window() {
assert!(screen.contains_rect(popup_rect(&u)));
}
/// A collection with a group `space` of two number variables with distinct values in two modes.
fn grouped_window() -> Ui {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
for (n, light, dark) in [("space/sm", 4, 8), ("space/md", 16, 32)] {
s.execute("variable.create", &json!({"collection": "Theme", "name": n, "type": "number", "value": light})).unwrap();
s.execute("variable.set", &json!({"variable": n, "mode": "Dark", "value": dark})).unwrap();
s.execute("variable.update", &json!({"variable": n, "scopes": ["GAP"], "description": n})).unwrap();
}
let mut u = Ui::new(s);
u.app.ui.variables = Some(Default::default());
for _ in 0..10 {
u.frame(vec![]);
}
u
}
/// Three colour variables: bravo and charlie also have Stroke in their scopes, delta does not.
fn colour_window() -> Ui {
let mut s = Session::new();
@@ -269,6 +286,52 @@ fn colour_window() -> Ui {
u
}
/// Right-click the group row `name` and choose Duplicate group.
fn duplicate_group(u: &mut Ui, name: &str) {
let p = row(u, name);
click(u, p, PointerButton::Secondary, Modifiers::NONE);
let menu = flat(&u.frame_output(vec![]).shapes);
click(u, text_center(&menu, "Duplicate group"), PointerButton::Primary, Modifiers::NONE);
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
#[test]
fn duplicate_group_copies_every_variable_in_both_modes_in_one_undo_step() {
let mut u = grouped_window();
let before = u.app.session.undo_labels(100).len();
duplicate_group(&mut u, "space");
let doc = u.app.session.doc().unwrap();
let vars = &doc.variables;
for (leaf, light, dark) in [("sm", 4, 8), ("md", 16, 32)] {
let old = vars.variables.iter().find(|v| v.name == format!("space/{leaf}")).unwrap();
let new = vars.variables.iter().find(|v| v.name == format!("space 2/{leaf}")).unwrap_or_else(|| panic!("no space 2/{leaf}"));
assert_ne!(old.id, new.id);
assert_eq!(new.values, old.values, "{leaf}: values in both modes");
assert_eq!(new.scopes, old.scopes);
assert_eq!(new.description, old.description);
let _ = (light, dark);
}
assert_eq!(names(&u).len(), 4);
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
assert_eq!(u.app.session.undo_labels(1), ["Duplicate group"]);
// The new group is listed directly after the original.
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "space 2"));
assert!(text_center(&shapes, "space 2").y > text_center(&shapes, "space").y);
u.app.session.undo().unwrap();
assert_eq!(names(&u), ["space/sm", "space/md"]);
}
#[test]
fn a_second_duplicate_group_takes_the_next_free_name() {
let mut u = grouped_window();
duplicate_group(&mut u, "space");
duplicate_group(&mut u, "space");
let n = names(&u);
assert!(n.contains(&"space 2/sm".to_string()) && n.contains(&"space 3/md".to_string()), "{n:?}");
assert_eq!(n.len(), 6);
}
fn open_edit_variables(u: &mut Ui) -> Vec<egui::Shape> {
let menu = select_three_and_open_menu(u);
assert!(has_text(&menu, "Edit variables"), "the bulk menu offers Edit variables");
+6 -1
View File
@@ -303,7 +303,12 @@ fn menu_icon(ui: &mut Ui, rect: Rect, icon: Icon, tip: &str, on: bool) -> Respon
if on || resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.menu_hover);
}
icons::paint(ui.painter(), rect.shrink(5.0), icon, t.menu_text);
let glyph = if (rect.width() - 24.0).abs() < 0.5 && (rect.height() - 24.0).abs() < 0.5 {
crate::widgets::icon_glyph_rect(rect)
} else {
rect.shrink(5.0)
};
icons::paint(ui.painter(), glyph, icon, t.menu_text);
resp
}
@@ -558,3 +558,17 @@ fn text_style_editor_binds_variables_from_its_fields() {
assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values");
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
}
/// The picker's 24 pt icon buttons paint their glyphs at 16 pt; a smaller button keeps a smaller one
/// (#2238).
#[test]
fn style_picker_icon_buttons_paint_16pt() {
use crate::header_icon_size_tests::{painted_rects, paints_icon_at_button_size};
use crate::icons::Icon;
for icon in [Icon::Plus, Icon::Detach, Icon::ChevronLeft] {
let painted = painted_rects(None, |ui| {
menu_icon(ui, Rect::from_min_size(egui::pos2(20.0, 20.0), vec2(24.0, 24.0)), icon, "x", false);
});
assert!(paints_icon_at_button_size(&painted, icon), "{icon:?} is not 16 pt");
}
}