mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:12:28 +00:00
Merge main into #2304
This commit is contained in:
commit
0263ec8a3a
42 files changed
+816
-72
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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) =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user