mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-12 08:51:11 +00:00
Merge main into #2244
This commit is contained in:
commit
7d860329a2
31 files changed
+1129
-69
No files matched your search
+122
-1
@@ -293,6 +293,10 @@ pub struct ParaList {
|
||||
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub direction: TextDirection,
|
||||
/// The paragraph's own wrap style (a wrap style can be set on the text layer, a paragraph or a
|
||||
/// text style); `None` follows the layer's [`TextData::wrap_style`].
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub wrap: Option<WrapStyle>,
|
||||
}
|
||||
|
||||
/// Character style. In runs, every field is an override; `None` means "inherit the base".
|
||||
@@ -337,6 +341,9 @@ pub struct ParagraphStyle {
|
||||
pub paragraph_indent: f64,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub list: ListStyle,
|
||||
/// The wrap style (Auto asks nothing).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub wrap_style: WrapStyle,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
}
|
||||
@@ -838,6 +845,33 @@ impl TextData {
|
||||
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
|
||||
}
|
||||
|
||||
/// Paragraph `p`'s wrap style: its own, else the layer's.
|
||||
pub fn para_wrap_style(&self, p: usize) -> WrapStyle {
|
||||
self.paragraphs.get(p).and_then(|pl| pl.wrap).unwrap_or(self.wrap_style)
|
||||
}
|
||||
|
||||
/// The layer level: every paragraph takes wrap style `w` (their own overrides go).
|
||||
pub fn set_wrap_style(&mut self, w: WrapStyle) {
|
||||
self.wrap_style = w;
|
||||
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
|
||||
let last = self.paragraph_count() - 1;
|
||||
self.set_para_lists_between(0, last, false, |pl| pl.wrap = None);
|
||||
}
|
||||
|
||||
/// The paragraph level: the paragraphs chars `start..end` touch take wrap style `w`; one equal
|
||||
/// to the layer's is no override.
|
||||
pub fn set_para_wrap_styles(&mut self, start: usize, end: usize, w: WrapStyle) {
|
||||
let layer = self.wrap_style;
|
||||
self.set_para_lists(start, end, |pl| pl.wrap = (w != layer).then_some(w));
|
||||
}
|
||||
|
||||
/// The effective wrap style the paragraphs chars `start..end` touch share, or None when they differ.
|
||||
pub fn common_wrap_style(&self, start: usize, end: usize) -> Option<WrapStyle> {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
let first = self.para_wrap_style(a);
|
||||
(a..=b).all(|p| self.para_wrap_style(p) == first).then_some(first)
|
||||
}
|
||||
|
||||
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
|
||||
fn fill_paragraphs(&mut self) {
|
||||
let n = self.paragraph_count();
|
||||
@@ -930,7 +964,16 @@ impl TextData {
|
||||
Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list,
|
||||
_ => self.list,
|
||||
};
|
||||
ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() }
|
||||
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
|
||||
let first = self.para_wrap_style(0);
|
||||
let wrap_style = if (1..self.paragraph_count()).all(|p| self.para_wrap_style(p) == first) { first } else { self.wrap_style };
|
||||
ParagraphStyle {
|
||||
paragraph_spacing: self.paragraph_spacing,
|
||||
paragraph_indent: self.paragraph_indent,
|
||||
list,
|
||||
wrap_style,
|
||||
features: self.features.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
|
||||
@@ -942,6 +985,7 @@ impl TextData {
|
||||
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
|
||||
let last = self.paragraph_count() - 1;
|
||||
self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list);
|
||||
self.set_wrap_style(ps.wrap_style);
|
||||
}
|
||||
|
||||
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
|
||||
@@ -952,6 +996,7 @@ impl TextData {
|
||||
self.paragraph_indent = ps.paragraph_indent;
|
||||
self.features.clone_from(&ps.features);
|
||||
self.set_para_lists(start, end, |pl| pl.list = ps.list);
|
||||
self.set_para_wrap_styles(start, end, ps.wrap_style);
|
||||
}
|
||||
|
||||
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
|
||||
@@ -970,6 +1015,7 @@ impl TextData {
|
||||
&& self.paragraph_indent == ps.paragraph_indent
|
||||
&& self.features == ps.features
|
||||
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
|
||||
&& self.common_wrap_style(start, end) == Some(ps.wrap_style)
|
||||
}
|
||||
|
||||
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
|
||||
@@ -1124,6 +1170,69 @@ mod case_tests {
|
||||
assert_eq!(t.para_list(0), ParaList::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_per_paragraph_loads_from_old_files_and_folds() {
|
||||
use WrapStyle::{Auto, Balance, Pretty};
|
||||
let old = r#"{"list":"numbered","indent":1}"#;
|
||||
assert_eq!(serde_json::from_str::<ParaList>(old).unwrap().wrap, None);
|
||||
let mut t = TextData::new("a\nb");
|
||||
let key = t.layout_key();
|
||||
t.set_para_wrap_styles(0, 0, Balance);
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Balance, Auto));
|
||||
assert_ne!(t.layout_key(), key);
|
||||
let back: TextData = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
|
||||
assert_eq!(back, t);
|
||||
t.set_para_wrap_styles(0, 0, Auto);
|
||||
assert!(t.paragraphs.is_empty());
|
||||
t.set_para_wrap_styles(0, 0, Balance);
|
||||
t.set_wrap_style(Pretty);
|
||||
assert!(t.paragraphs.is_empty());
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_loads_from_old_paragraph_styles_and_texts() {
|
||||
use WrapStyle::Auto;
|
||||
// A paragraph style saved before it carried a wrap style.
|
||||
let old = r#"{"paragraph_spacing":4.0,"list":"bulleted"}"#;
|
||||
let ps: ParagraphStyle = serde_json::from_str(old).unwrap();
|
||||
assert_eq!((ps.wrap_style, ps.paragraph_spacing, ps.list), (Auto, 4.0, ListStyle::Bulleted));
|
||||
assert!(!serde_json::to_string(&ParagraphStyle::default()).unwrap().contains("wrap_style"), "Auto is not saved");
|
||||
// A text saved before its paragraphs carried a wrap style.
|
||||
let mut t = TextData::new("a\nb");
|
||||
t.set_para_lists(0, 0, |p| p.list = ListStyle::Bulleted);
|
||||
let mut v = serde_json::to_value(&t).unwrap();
|
||||
for p in v["paragraphs"].as_array_mut().unwrap() {
|
||||
assert!(p.as_object_mut().unwrap().remove("wrap").is_none(), "Auto is not saved");
|
||||
}
|
||||
v.as_object_mut().unwrap().remove("wrap_style");
|
||||
let old: TextData = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.wrap_style, Auto);
|
||||
assert_eq!(old.paragraphs.len(), 2);
|
||||
assert!(old.paragraphs.iter().all(|p| p.wrap.is_none()));
|
||||
assert_eq!((old.para_wrap_style(0), old.para_wrap_style(1)), (Auto, Auto));
|
||||
assert_eq!(old, t);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_wrap_style_clears_an_override_on_the_empty_last_paragraph() {
|
||||
use WrapStyle::{Auto, Balance, Pretty};
|
||||
let mut t = TextData::new("ab\n");
|
||||
assert_eq!(t.paragraph_count(), 2);
|
||||
// The empty paragraph after the final newline: a caret there reaches it.
|
||||
t.set_para_wrap_styles(3, 3, Balance);
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Auto, Balance));
|
||||
t.set_wrap_style(Pretty);
|
||||
assert!(t.paragraphs.is_empty(), "{:?}", t.paragraphs);
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty));
|
||||
// Back to the layer's own style: no override is left on either paragraph.
|
||||
t.set_para_wrap_styles(3, 3, Auto);
|
||||
assert_eq!(t.para_wrap_style(1), Auto);
|
||||
t.set_wrap_style(Auto);
|
||||
assert!(t.paragraphs.is_empty());
|
||||
assert_eq!(t.common_wrap_style(0, t.char_count()), Some(Auto));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_list_starts_touches_only_starts() {
|
||||
use ListStyle::{None as P, Numbered as N};
|
||||
@@ -1190,4 +1299,16 @@ mod whole_text_paragraph_style_tests {
|
||||
plain.take_paragraph_style(&ps);
|
||||
assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_made_from_the_whole_layer_counts_a_trailing_empty_paragraphs_wrap_style() {
|
||||
// The first two paragraphs are Balance, the empty one after the final newline keeps the
|
||||
// layer's Auto: they differ, so the style takes the layer's wrap style, not Balance.
|
||||
let mut t = TextData::new("a\nb\n");
|
||||
t.set_para_wrap_styles(0, 3, WrapStyle::Balance);
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1), t.para_wrap_style(2)), (WrapStyle::Balance, WrapStyle::Balance, WrapStyle::Auto));
|
||||
assert_eq!(t.paragraph_style().wrap_style, WrapStyle::Auto);
|
||||
t.set_para_wrap_styles(4, 4, WrapStyle::Balance);
|
||||
assert_eq!(t.paragraph_style().wrap_style, WrapStyle::Balance, "all three Balance");
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"lineHeight",
|
||||
"lineIndentations",
|
||||
"lineTypes",
|
||||
"lineWrapStyles",
|
||||
"listSpacing",
|
||||
"listStartOffsets",
|
||||
"listStyle",
|
||||
@@ -466,8 +467,14 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
|
||||
}
|
||||
if t.wrap_style != figcraft_doc::WrapStyle::Auto {
|
||||
insert_prop(o, options, "wrapStyle", || json!(t.wrap_style));
|
||||
// The paragraphs' effective wrap styles: one for the layer when they agree, else each one's.
|
||||
let wraps: Vec<figcraft_doc::WrapStyle> = (0..t.paragraph_count()).map(|p| t.para_wrap_style(p)).collect();
|
||||
if let Some(first) = wraps.first().copied().filter(|w| *w != figcraft_doc::WrapStyle::Auto)
|
||||
&& wraps.iter().all(|w| *w == first)
|
||||
{
|
||||
insert_prop(o, options, "wrapStyle", || json!(first));
|
||||
} else if wraps.iter().any(|w| *w != figcraft_doc::WrapStyle::Auto) {
|
||||
insert_prop(o, options, "lineWrapStyles", || wraps.iter().map(|w| json!(w)).collect());
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
|
||||
@@ -258,3 +258,187 @@ fn template_and_table_commands_survive_junk() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Resizing a table (#2172): a height change goes into its rows, never into blank space.
|
||||
|
||||
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
/// A selected 3 x 3 table at 0,0 (480 x 122: a line under all but the last row adds 1 to each), with `texts` in the first row.
|
||||
fn table_3x3(texts: [&str; 3]) -> (Session, NodeId) {
|
||||
let mut s = board();
|
||||
let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [texts, ["", "", ""], ["", "", ""]]})).unwrap();
|
||||
(s, id(&r, "id"))
|
||||
}
|
||||
|
||||
fn rows_of(s: &Session, t: NodeId) -> Vec<NodeId> {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.node(t).unwrap().children.iter().copied().filter(|c| !node(s, *c).child_layout.absolute).collect()
|
||||
}
|
||||
|
||||
fn row_heights(s: &Session, t: NodeId) -> Vec<f64> {
|
||||
rows_of(s, t).into_iter().map(|r| node(s, r).height).collect()
|
||||
}
|
||||
|
||||
fn column_widths(s: &Session, t: NodeId) -> Vec<f64> {
|
||||
let r = rows_of(s, t)[0];
|
||||
node(s, r).children.iter().map(|c| node(s, *c).width).collect()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 0.6
|
||||
}
|
||||
|
||||
fn all_near(got: &[f64], want: &[f64]) -> bool {
|
||||
got.len() == want.len() && got.iter().zip(want).all(|(a, b)| near(*a, *b))
|
||||
}
|
||||
|
||||
fn is_hug(s: &Session, t: NodeId) -> bool {
|
||||
node(s, t).child_layout.sizing_v == figcraft_doc::Sizing::Hug
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_drag_grows_only_the_last_row() {
|
||||
// The programmatic route.
|
||||
let (mut s, t) = table_3x3(["Alpha", "", ""]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t));
|
||||
let n = node(&s, t);
|
||||
assert!(near(n.width, 480.0) && near(n.height, 272.0), "{}x{}", n.width, n.height);
|
||||
assert!(is_hug(&s, t), "still hugging its rows");
|
||||
assert!(all_near(&column_widths(&s, t), &[160.0; 3]));
|
||||
assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t));
|
||||
// The pointer route: the bottom edge dragged down by 150.
|
||||
let (mut s, t) = table_3x3(["Alpha", "", ""]);
|
||||
drag(&mut s, (240.0, 122.0), (240.0, 272.0));
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(near(node(&s, t).height, 272.0) && near(node(&s, t).width, 480.0));
|
||||
assert!(is_hug(&s, t));
|
||||
assert!(all_near(&column_widths(&s, t), &[160.0; 3]));
|
||||
assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t));
|
||||
// The text keeps its size.
|
||||
let row0 = rows_of(&s, t)[0];
|
||||
let cell = node(&s, node(&s, row0).children[0]);
|
||||
let NodeKind::Text(td) = &node(&s, cell.children[0]).kind else { panic!("text") };
|
||||
assert_eq!(td.style.font_size, 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corner_drag_shares_the_change_over_every_row_and_column() {
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
drag(&mut s, (480.0, 122.0), (570.0, 182.0));
|
||||
let n = node(&s, t);
|
||||
assert!(near(n.width, 570.0) && near(n.height, 182.0), "{}x{}", n.width, n.height);
|
||||
assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t));
|
||||
let xs: Vec<f64> = lines(&s, t).iter().map(|l| l.0).collect();
|
||||
assert!(all_near(&xs, &[189.5, 379.5]), "{xs:?}");
|
||||
assert!(is_hug(&s, t));
|
||||
// The same through `transform.resize`.
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
s.execute("transform.resize", &json!({"ids": [t], "width": 570, "height": 182})).unwrap();
|
||||
assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_side_drag_changes_no_row() {
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"width": 680}})).unwrap();
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0));
|
||||
assert!(is_hug(&s, t));
|
||||
// A pointer drag of the right edge.
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
drag(&mut s, (480.0, 60.0), (680.0, 60.0));
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0));
|
||||
assert!(is_hug(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_never_shrinks_below_its_cells() {
|
||||
let (mut s, t) = table_3x3(["Alpha", "Bravo longer text", "Charlie"]);
|
||||
drag(&mut s, (480.0, 122.0), (50.0, 32.0));
|
||||
let n = node(&s, t);
|
||||
assert!(near(n.width, 120.0), "width {}", n.width);
|
||||
assert!(all_near(&column_widths(&s, t), &[40.0; 3]), "{:?}", column_widths(&s, t));
|
||||
let rows = row_heights(&s, t);
|
||||
assert!(rows[0] > 40.0, "row 1 holds its wrapped text: {rows:?}");
|
||||
assert!(near(rows[1], 41.0) && near(rows[2], 40.0), "{rows:?}");
|
||||
assert!(near(n.height, rows.iter().sum::<f64>()), "{} vs {rows:?}", n.height);
|
||||
assert!(is_hug(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_table_still_grows_with_its_text_and_undoes_in_one_step() {
|
||||
// Text measures only once system fonts are scanned (without craft-fonts), so scan them here
|
||||
// without depending on another test having scanned them first.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
drag(&mut s, (240.0, 122.0), (240.0, 272.0));
|
||||
assert!(near(node(&s, t).height, 272.0));
|
||||
// Typing in a last-row cell: the table grows once the text needs more than 190.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 200.0, 1.0)).unwrap();
|
||||
assert!(s.ds().unwrap().text_edit.is_some(), "typing in the last row's cell");
|
||||
let long = (0..12).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
|
||||
crate::textedit::insert(&mut s, &long).unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
assert!(node(&s, t).height > 272.0, "grew with its text: {}", node(&s, t).height);
|
||||
assert!(is_hug(&s, t));
|
||||
// Undo: the typing, then the resize, to the table as made.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(node(&s, t).height, 272.0), "{}", node(&s, t).height);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let n = node(&s, t);
|
||||
assert!(near(n.width, 480.0) && near(n.height, 122.0), "{}x{}", n.width, n.height);
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_resizes_sync_in_live_sessions() {
|
||||
let s = board();
|
||||
let mut room = Room::new(s.doc().unwrap().clone(), 2, 12);
|
||||
let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id");
|
||||
room.settle();
|
||||
room.check();
|
||||
room.s(0).execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
|
||||
room.settle();
|
||||
room.check();
|
||||
let doc = room.server.document();
|
||||
let heights: Vec<f64> =
|
||||
doc.node(t).unwrap().children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect();
|
||||
assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}");
|
||||
assert!(near(doc.node(t).unwrap().height, 272.0));
|
||||
assert_eq!(doc.node(t).unwrap().child_layout.sizing_v, figcraft_doc::Sizing::Hug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_a_table_survives_junk_and_saves_the_rows() {
|
||||
for h in [json!(1e308), json!(-5), json!("NaN"), json!(null), json!(0)] {
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute("node.set", &json!({"id": t, "props": {"height": h}})) {
|
||||
panic!("{cmd} panicked on {h}: {msg}");
|
||||
}
|
||||
for p in [json!({"ids": [t], "width": h, "height": h}), json!({"ids": [t], "height": h}), json!({"ids": [t], "width": h})] {
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute("transform.resize", &p) {
|
||||
panic!("{cmd} panicked on {p}: {msg}");
|
||||
}
|
||||
}
|
||||
let n = node(&s, t);
|
||||
assert!(n.width.is_finite() && n.height.is_finite(), "{h}: {}x{}", n.width, n.height);
|
||||
}
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
|
||||
let heights: Vec<f64> =
|
||||
back.node(t).unwrap().children.iter().map(|c| back.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect();
|
||||
assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}");
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTree
|
||||
if !is_board(doc) {
|
||||
return;
|
||||
}
|
||||
crate::board_table::settle_resize(doc, before, touched);
|
||||
for &id in touched {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if let Some(BoardItem::Shape { shape }) = n.board
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws
|
||||
//! a line under itself and the column lines are thin rectangles over the table that stretch with
|
||||
//! it (they are rebuilt after every edit, like the first row's header tint).
|
||||
//!
|
||||
//! Resizing (measured in FigJam's web app, 2026-11-09, #2172): a table never scales its text or
|
||||
//! its rows in proportion. A height change goes into the rows ([`settle_resize`]): the bottom or
|
||||
//! top edge grows the last row, a corner drag shares the change equally over every row (every
|
||||
//! cell fills its row, so the width is already shared equally), and the table keeps hugging its
|
||||
//! rows. A cell is at least [`MIN_CELL`] (40; FigJam's floor is about 52) wide and tall, and a row
|
||||
//! is never shorter than its wrapped text. There are no per-column widths yet: a side drag of
|
||||
//! the right edge splits the change equally where FigJam grows only the last column.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{
|
||||
@@ -22,6 +32,10 @@ pub const MAX_COLUMNS: usize = 20;
|
||||
/// A new column's width, and a cell's height before its text is measured.
|
||||
const CELL_W: f64 = 160.0;
|
||||
const CELL_H: f64 = 40.0;
|
||||
/// The least a cell is wide or tall (FigJam's is about 52).
|
||||
pub(crate) const MIN_CELL: f64 = CELL_H;
|
||||
/// The most a resize may ask one row to be tall.
|
||||
const MAX_ROW_H: f64 = 100_000.0;
|
||||
/// Room around a cell's text: [top, right, bottom, left].
|
||||
const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0];
|
||||
const TEXT_SIZE: f64 = 14.0;
|
||||
@@ -125,6 +139,7 @@ fn make_cell(doc: &mut Document, row: NodeId, index: Option<usize>, width: f64,
|
||||
n.child_layout.sizing_h = Sizing::Fill;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
n.child_layout.min_h = Some(CELL_H);
|
||||
n.child_layout.min_w = Some(MIN_CELL);
|
||||
})?;
|
||||
add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?;
|
||||
Ok(cell)
|
||||
@@ -259,6 +274,57 @@ fn table_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A table resized by its height or a corner (the resize leaves it at a fixed height): the change
|
||||
/// goes into its rows, as in FigJam, and the table hugs them again. A bottom or top edge (or a
|
||||
/// height set by command) grows or shrinks the last row; a drag that changed both axes shares the
|
||||
/// change equally over every row. A row never gets shorter than [`MIN_CELL`] or than its text (the
|
||||
/// table hugs it), and the table is never narrower than its columns' minimum. A table already
|
||||
/// hugging (a peer's synced edit, a second pass) is left alone, so the change is made once.
|
||||
pub(crate) fn settle_resize(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
|
||||
let mut tables = BTreeSet::new();
|
||||
for &id in touched {
|
||||
if let Some(t) = table_of(doc, id) {
|
||||
tables.insert(t);
|
||||
}
|
||||
}
|
||||
for table in tables {
|
||||
let (Some(now), Some(was)) = (doc.node(table), before.node(table)) else { continue };
|
||||
if now.child_layout.sizing_v != Sizing::Fixed {
|
||||
continue;
|
||||
}
|
||||
let (w, h, bw, bh) = (now.width, now.height, was.width, was.height);
|
||||
let (rows, _) = parts(doc, table);
|
||||
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
|
||||
let dh = h - bh;
|
||||
if dh.is_finite() && dh.abs() > 1e-6 && !rows.is_empty() {
|
||||
let both = w.is_finite() && bw.is_finite() && (w - bw).abs() > 1e-6;
|
||||
let targets: Vec<NodeId> = if both { rows.clone() } else { rows.last().copied().into_iter().collect() };
|
||||
let share = dh / targets.len() as f64;
|
||||
for row in targets {
|
||||
let base = row_content(before, row).or_else(|| row_content(doc, row)).unwrap_or(CELL_H);
|
||||
let min_h = (base + share).clamp(MIN_CELL, MAX_ROW_H);
|
||||
for cell in cells(doc, row) {
|
||||
if let Some(c) = doc.node_mut(cell) {
|
||||
c.child_layout.min_h = Some(min_h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = doc.node_mut(table) {
|
||||
let least = MIN_CELL * columns as f64;
|
||||
if t.width.is_nan() || t.width < least {
|
||||
t.width = least;
|
||||
}
|
||||
t.child_layout.sizing_v = Sizing::Hug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How tall a row's cells are: the row's height less the line under it.
|
||||
fn row_content(doc: &Document, row: NodeId) -> Option<f64> {
|
||||
cells(doc, row).into_iter().filter_map(|c| doc.node(c).map(|n| n.height)).reduce(f64::max)
|
||||
}
|
||||
|
||||
/// The table `id` is or is in.
|
||||
fn table_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table)))
|
||||
|
||||
@@ -29,7 +29,7 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
|
||||
(auto|px|\"10%\" of the font size; the offset runs from the baseline down to an underline's top edge), textDecorationSkipInk (underlines break around descenders), \
|
||||
textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
|
||||
listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \
|
||||
listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
|
||||
listSpacing (px between list items, in place of paragraphSpacing), wrapStyle (auto|balance|pretty; with start/end: the paragraphs those chars touch), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
|
||||
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \
|
||||
path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \
|
||||
node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \
|
||||
@@ -1008,7 +1008,14 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
|
||||
}
|
||||
"leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?,
|
||||
"wrapStyle" | "textWrap" => {
|
||||
text_mut(cmd, doc.get_mut(id)?)?.wrap_style = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?
|
||||
let w: figcraft_doc::WrapStyle = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?;
|
||||
let t = text_mut(cmd, doc.get_mut(id)?)?;
|
||||
if props.contains_key("start") || props.contains_key("end") {
|
||||
let (start, end) = range_of(props, t.char_count());
|
||||
t.set_para_wrap_styles(start, end, w);
|
||||
} else {
|
||||
t.set_wrap_style(w);
|
||||
}
|
||||
}
|
||||
"maxLines" => {
|
||||
let lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32;
|
||||
|
||||
@@ -275,6 +275,9 @@ fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
|
||||
"paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?,
|
||||
"paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?,
|
||||
"listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
|
||||
"wrapStyle" | "textWrap" | "wrap_style" => {
|
||||
ps.wrap_style = super::props::enum_of(x).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?;
|
||||
}
|
||||
"features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => {
|
||||
ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect();
|
||||
}
|
||||
@@ -289,10 +292,11 @@ fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
|
||||
fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option<ParagraphStyle> {
|
||||
let t = n.kind.text()?;
|
||||
let mut ps = t.paragraph_style();
|
||||
if let Some((a, b)) = range
|
||||
&& let Some(pl) = t.common_para_list(a, b)
|
||||
{
|
||||
ps.list = pl.list;
|
||||
if let Some((a, b)) = range {
|
||||
if let Some(pl) = t.common_para_list(a, b) {
|
||||
ps.list = pl.list;
|
||||
}
|
||||
ps.wrap_style = t.common_wrap_style(a, b).unwrap_or(t.wrap_style);
|
||||
}
|
||||
Some(ps)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,15 @@ fn set_direction(s: &mut Session, p: &Value, d: TextDirection) -> Result<Value>
|
||||
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d))
|
||||
}
|
||||
|
||||
/// The wrap style of the targeted texts: the edited paragraphs while editing (the paragraph
|
||||
/// level), else the whole layer (the layer level: every paragraph, overrides included).
|
||||
fn set_wrap_style(s: &mut Session, p: &Value, w: figcraft_doc::WrapStyle) -> Result<Value> {
|
||||
match textedit::selected_range(s) {
|
||||
Some((_, a, b)) => para(s, p, |t| t.set_para_wrap_styles(a, b, w)),
|
||||
None => para(s, p, |t| t.set_wrap_style(w)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether every targeted char has `pred` (to decide toggle direction).
|
||||
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
|
||||
let Ok(doc) = s.doc() else { return false };
|
||||
@@ -445,12 +454,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"text.wrapStyle",
|
||||
"Wrap style",
|
||||
"{style: auto|balance|pretty, ids?} where lines break when the text wraps: Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
|
||||
"{style: auto|balance|pretty, ids?} where lines break when the text wraps (while editing: the selected paragraphs; else the whole layer): Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
|
||||
true,
|
||||
|s, p| {
|
||||
let w: figcraft_doc::WrapStyle =
|
||||
get(p, "style").and_then(super::props::enum_of).ok_or_else(|| bad("text.wrapStyle", "`style`: auto|balance|pretty"))?;
|
||||
para(s, p, |t| t.wrap_style = w)
|
||||
set_wrap_style(s, p, w)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
|
||||
@@ -100,12 +100,16 @@ fn zoom_to(s: &mut Session, z: f64) -> Result<Value> {
|
||||
Ok(json!({"zoom": v.zoom}))
|
||||
}
|
||||
|
||||
/// How far an arrow key pans the view with nothing selected (screen pixels; Shift: ten times).
|
||||
pub const PAN_STEP_PX: f64 = 20.0;
|
||||
/// How far an arrow key pans the view with nothing selected (screen pixels). Measured in Figma at
|
||||
/// 100% zoom: about 64 px (62-64 in two trials, +/-3 px).
|
||||
pub const PAN_STEP_PX: f64 = 64.0;
|
||||
|
||||
/// Shift multiplies the arrow pan by this much (Figma: about 320 px, five steps).
|
||||
pub const PAN_BIG_STEPS: f64 = 5.0;
|
||||
|
||||
/// `view.pan`: the arrow keys with nothing selected move the view by keyboard steps.
|
||||
fn pan(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * 10.0 } else { PAN_STEP_PX };
|
||||
let step = if boolean(p, "big").unwrap_or(false) { PAN_STEP_PX * PAN_BIG_STEPS } else { PAN_STEP_PX };
|
||||
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
|
||||
let (dx, dy) = (dir("dx"), dir("dy"));
|
||||
let v = view(s)?;
|
||||
@@ -318,7 +322,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
|
||||
toggle(s, p, "view.multiplayerCursors")
|
||||
}),
|
||||
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
|
||||
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (five times as far, like Shift)}", false, pan),
|
||||
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
|
||||
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
|
||||
toggle(s, p, "view.outlinesSelection")
|
||||
@@ -698,4 +702,45 @@ mod start_on_home_tests {
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(!old.start_on_home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("view.pan", &json!({"dx": 1})).unwrap();
|
||||
let v = &s.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (364.0, 200.0), "one press pans 64 px");
|
||||
s.execute("view.pan", &json!({"dy": -1, "big": true})).unwrap();
|
||||
let v = &s.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (364.0, -120.0), "Shift pans 320 px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_arrow_pan_is_the_same_screen_distance_at_any_zoom_on_both_axes() {
|
||||
// (zoom, dx, dy, big, expected centre change in document px)
|
||||
let cases = [
|
||||
(2.0, 1.0, 0.0, false, 32.0, 0.0),
|
||||
(0.5, 1.0, 0.0, false, 128.0, 0.0),
|
||||
(2.0, 0.0, 1.0, true, 0.0, 160.0),
|
||||
(0.5, 0.0, 1.0, true, 0.0, 640.0),
|
||||
(1.0, -1.0, 0.0, false, -64.0, 0.0),
|
||||
(1.0, 1.0, 0.0, true, 320.0, 0.0),
|
||||
(1.0, -1.0, 0.0, true, -320.0, 0.0),
|
||||
(1.0, 0.0, -1.0, false, 0.0, -64.0),
|
||||
(1.0, 0.0, 1.0, false, 0.0, 64.0),
|
||||
(1.0, 0.0, -1.0, true, 0.0, -320.0),
|
||||
];
|
||||
for (zoom, dx, dy, big, ex, ey) in cases {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("view.set", &json!({"zoom": zoom, "cx": 300, "cy": 200})).unwrap();
|
||||
s.execute("view.pan", &json!({"dx": dx, "dy": dy, "big": big})).unwrap();
|
||||
let v = &s.ds().unwrap().view;
|
||||
let got = (v.cx - 300.0, v.cy - 200.0);
|
||||
assert!(
|
||||
(got.0 - ex).abs() < 1e-6 && (got.1 - ey).abs() < 1e-6,
|
||||
"zoom {zoom} dx {dx} dy {dy} big {big}: moved {got:?}, expected {:?}",
|
||||
(ex, ey)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -302,10 +302,12 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
|
||||
TextAlignH::Right => put("text-align", "right".into()),
|
||||
TextAlignH::Justified => put("text-align", "justify".into()),
|
||||
}
|
||||
match tx.wrap_style {
|
||||
figcraft_doc::WrapStyle::Auto => {}
|
||||
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
|
||||
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
|
||||
// CSS has one `text-wrap` per element: written when every paragraph has the same one.
|
||||
let wrap = (0..tx.paragraph_count()).map(|p| tx.para_wrap_style(p)).collect::<Vec<_>>();
|
||||
match wrap.first().copied().filter(|w| wrap.iter().all(|x| x == w)) {
|
||||
Some(figcraft_doc::WrapStyle::Balance) => put("text-wrap", "balance".into()),
|
||||
Some(figcraft_doc::WrapStyle::Pretty) => put("text-wrap", "pretty".into()),
|
||||
_ => {}
|
||||
}
|
||||
// A base direction forced on every paragraph (CSS has one per element).
|
||||
let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d));
|
||||
|
||||
@@ -418,6 +418,51 @@ fn text_styles_carry_paragraph_spacing_indent_lists_and_features() {
|
||||
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_carry_the_wrap_style() {
|
||||
// Explore text properties: a wrap style can be set on the text layer, a paragraph or a text
|
||||
// style. A style made from a text takes its wrap style, applying it sets it,
|
||||
// editing it on a styled text unlinks it.
|
||||
use figcraft_doc::WrapStyle::{Auto, Balance, Pretty};
|
||||
let mut s = Session::new();
|
||||
let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() };
|
||||
let t = text(&mut s, 0.0);
|
||||
run(&mut s, "node.set", json!({"id": t, "props": {"characters": "One\nTwo", "wrapStyle": "balance"}}));
|
||||
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Even", "from": t})));
|
||||
let listed = run(&mut s, "style.list", json!({"type": "text"}));
|
||||
let even = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap();
|
||||
assert_eq!(even["paragraph"]["wrap_style"], json!("balance"), "{even}");
|
||||
// Applying it sets the layer's wrap style, with no paragraph overrides, and links the layer.
|
||||
let t2 = text(&mut s, 300.0);
|
||||
run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB"}}));
|
||||
run(&mut s, "style.apply", json!({"ids": [t2], "style": st}));
|
||||
let d = td(&s, t2);
|
||||
assert_eq!(d.wrap_style, Balance);
|
||||
assert!(d.paragraphs.iter().all(|p| p.wrap.is_none()), "{:?}", d.paragraphs);
|
||||
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()));
|
||||
// Redefining the style moves the layer and keeps the link.
|
||||
run(&mut s, "style.update", json!({"style": st, "value": {"wrapStyle": "pretty"}}));
|
||||
let d = td(&s, t2);
|
||||
assert_eq!((d.para_wrap_style(0), d.para_wrap_style(1)), (Pretty, Pretty));
|
||||
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update");
|
||||
// Editing the wrap style on the styled text unlinks it.
|
||||
run(&mut s, "node.set", json!({"id": t2, "props": {"wrapStyle": "auto"}}));
|
||||
assert_eq!(td(&s, t2).wrap_style, Auto);
|
||||
assert_eq!(node(&s, t2).styles.text, None, "wrap style edited: unlinked");
|
||||
// On a range, the paragraphs touched take it as their own and the layer keeps its style.
|
||||
let t3 = text(&mut s, 600.0);
|
||||
run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB"}}));
|
||||
run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1}));
|
||||
let d = td(&s, t3);
|
||||
assert_eq!((d.wrap_style, d.para_wrap_style(0), d.para_wrap_style(1)), (Auto, Pretty, Auto));
|
||||
// A definition given as a value takes the node.set spellings; junk is refused.
|
||||
let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": {"wrapStyle": "balance"}})));
|
||||
let want = ParagraphStyle { wrap_style: Balance, ..Default::default() };
|
||||
assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want));
|
||||
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"wrapStyle": "zigzag"}})).is_err());
|
||||
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"textWrap": 3}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_are_organised_by_slash_names_order_and_duplicates() {
|
||||
// Documented: "/" in a name groups styles in folders; moving a style to another folder renames
|
||||
|
||||
@@ -495,6 +495,92 @@ fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
|
||||
assert_eq!(lines(&s), [(0, 9), (9, 16), (16, 21)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_per_paragraph_overrides_the_layers() {
|
||||
// Explore text properties: a wrap style can be set on the text layer, a paragraph or a text
|
||||
// style. A paragraph's own wrap style beats the layer's; setting the layer
|
||||
// sets every paragraph again.
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "aaa bbb ccc ddd eee\naaa bbb ccc ddd eee", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 136}})).unwrap();
|
||||
let lines = |s: &Session| -> Vec<(usize, usize)> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.lines.iter().map(|l| (l.char_start, l.char_end)).collect()
|
||||
};
|
||||
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
|
||||
let paragraphs = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().paragraphs.clone();
|
||||
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 36), (36, 39)], "Auto: four words and one in each paragraph");
|
||||
// The second paragraph alone, by a range on node.set.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 20, "end": 20}})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 32), (32, 39)]);
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance"]));
|
||||
assert!(inspect(&s).get("wrapStyle").is_none());
|
||||
assert!(!css(&mut s).contains("text-wrap"), "{}", css(&mut s));
|
||||
// While editing, the caret's paragraph (the Wrap style control).
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 0})).unwrap();
|
||||
s.execute("text.wrapStyle", &json!({"style": "pretty"})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 12), (12, 20), (20, 32), (32, 39)]);
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["pretty", "balance"]));
|
||||
// Undo restores the paragraph's override.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 32), (32, 39)]);
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance"]));
|
||||
// Enter inside the balanced paragraph carries the style to the new paragraph.
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 22})).unwrap();
|
||||
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "balance"]));
|
||||
// The layer level sets every paragraph and folds the overrides away.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
|
||||
assert!(paragraphs(&s).iter().all(|p| p.wrap.is_none()), "{:?}", paragraphs(&s));
|
||||
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
|
||||
assert!(css(&mut s).contains("text-wrap: balance;"), "{}", css(&mut s));
|
||||
assert!(s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "zigzag", "start": 0, "end": 0}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_wrap_style_without_editing_sets_the_layer_and_folds_paragraph_overrides() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "one\ntwo\nthree", json!({}));
|
||||
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
let td = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().clone();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 5, "end": 5}})).unwrap();
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "pretty", "start": 9, "end": 9}})).unwrap();
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "pretty"]));
|
||||
assert!(s.ds().unwrap().text_edit.is_none(), "no edit session");
|
||||
s.execute("text.wrapStyle", &json!({"ids": [t], "style": "balance"})).unwrap();
|
||||
let after = td(&s);
|
||||
assert!(after.paragraphs.iter().all(|p| p.wrap.is_none()), "{:?}", after.paragraphs);
|
||||
assert_eq!(after.wrap_style, figcraft_doc::WrapStyle::Balance);
|
||||
assert!((0..3).all(|p| after.para_wrap_style(p) == figcraft_doc::WrapStyle::Balance));
|
||||
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
|
||||
assert!(inspect(&s).get("lineWrapStyles").is_none());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "pretty"]), "undo restores the overrides");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_wrap_style_only_when_not_auto_and_counts_a_trailing_empty_paragraph() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "one\ntwo\n", json!({}));
|
||||
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
let quiet = |s: &Session| inspect(s).get("wrapStyle").is_none() && inspect(s).get("lineWrapStyles").is_none();
|
||||
assert!(quiet(&s), "uniform Auto says nothing");
|
||||
// An override on the empty last paragraph (a caret after the final newline) is listed with the others.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 8, "end": 8}})).unwrap();
|
||||
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "auto", "balance"]));
|
||||
assert!(inspect(&s).get("wrapStyle").is_none());
|
||||
// Every paragraph the same non-Auto style: the one key; back to Auto: neither.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
|
||||
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
|
||||
assert!(inspect(&s).get("lineWrapStyles").is_none());
|
||||
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "auto"}})).unwrap();
|
||||
assert!(quiet(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
|
||||
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
|
||||
|
||||
@@ -278,6 +278,7 @@ fn lists(v: &Value, td: &mut TextData) {
|
||||
Some("RTL") => TextDirection::Rtl,
|
||||
_ => TextDirection::Auto,
|
||||
},
|
||||
wrap: None,
|
||||
})
|
||||
.collect();
|
||||
// Clamp the levels and fold one style for all back into the layer's list style.
|
||||
|
||||
@@ -869,7 +869,7 @@ fn line_width(items: &[Item], (start, end, _, _): LineRange) -> f64 {
|
||||
/// to give a word to the lines after it ("keeps each line full, but adjusts the last four lines to
|
||||
/// avoid an orphan"). `rebreak` lays the text out again with a width per paragraph and line.
|
||||
fn restyle_breaks(
|
||||
style: WrapStyle,
|
||||
style_of: &dyn Fn(usize) -> WrapStyle,
|
||||
w: f64,
|
||||
lines: Vec<LineRange>,
|
||||
items: &[Item],
|
||||
@@ -905,7 +905,7 @@ fn restyle_breaks(
|
||||
}
|
||||
// Words wider than the box break where they must; the re-breaking adds no such break.
|
||||
let base = emergency_breaks(items, word_breaks, ¶);
|
||||
match style {
|
||||
match style_of(p) {
|
||||
WrapStyle::Auto => {}
|
||||
WrapStyle::Balance => {
|
||||
// The narrowest width that still gives `n` lines, to half a pixel.
|
||||
@@ -992,8 +992,8 @@ fn wrapped_lines(
|
||||
let lines = from_saved.or_else(|| breaks(&|_, _| wrap, None)).unwrap_or_default();
|
||||
// Balance and Pretty re-break the paragraphs at narrower widths (never saved breaks).
|
||||
match wrap {
|
||||
Some(w) if !followed && text.wrap_style != WrapStyle::Auto => {
|
||||
restyle_breaks(text.wrap_style, w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
|
||||
Some(w) if !followed && (0..text.paragraph_count()).any(|p| text.para_wrap_style(p) != WrapStyle::Auto) => {
|
||||
restyle_breaks(&|p| text.para_wrap_style(p), w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
|
||||
breaks(wrap_of, None).unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -534,6 +534,9 @@ name = "English"
|
||||
"Edit padding on all sides" = "Edit padding on all sides"
|
||||
"Edit style" = "Edit style"
|
||||
"Edit variable" = "Edit variable"
|
||||
"Edit variables" = "Edit variables"
|
||||
"Scope" = "Scope"
|
||||
"No scope is shared by all the selected variables" = "No scope is shared by all the selected variables"
|
||||
"Edit with the builder" = "Edit with the builder"
|
||||
"Edited {when}" = "Edited {when}"
|
||||
"Effect settings" = "Effect settings"
|
||||
|
||||
@@ -883,9 +883,17 @@ pub fn tab(resp: &Response, label: &str, selected: bool) {
|
||||
}
|
||||
|
||||
pub fn checkbox(resp: &Response, label: &str, on: bool) {
|
||||
checkbox_tri(resp, label, Some(on));
|
||||
}
|
||||
|
||||
/// A checkbox that can be mixed (`None`, announced as "mixed" rather than unchecked).
|
||||
pub fn checkbox_tri(resp: &Response, label: &str, state: Option<bool>) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::CheckBox);
|
||||
n.set_toggled(toggled(on));
|
||||
n.set_toggled(match state {
|
||||
Some(on) => toggled(on),
|
||||
None => Toggled::Mixed,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -431,3 +431,80 @@ fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() {
|
||||
}
|
||||
assert!(!seen.contains(&left) && !seen.contains(&right), "Tab never stops on a panel edge: {seen:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_checkbox_is_announced_as_mixed_and_a_checked_one_as_true() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::widgets::checkbox_tri(ui, None, "Mixed one");
|
||||
crate::widgets::checkbox_tri(ui, Some(true), "Checked one");
|
||||
crate::widgets::checkbox_tri(ui, Some(false), "Plain one");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let nodes: Vec<Node> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect();
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Mixed one").toggled(), Some(Toggled::Mixed));
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Checked one").toggled(), Some(Toggled::True));
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Plain one").toggled(), Some(Toggled::False));
|
||||
}
|
||||
|
||||
/// Runs one headless frame of a lone tri-state checkbox; returns whether it was clicked.
|
||||
fn tri_frame(ctx: &egui::Context, state: Option<bool>, events: Vec<Event>) -> bool {
|
||||
let mut clicked = false;
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
clicked = crate::widgets::checkbox_tri(ui, state, "Keyed one");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
clicked
|
||||
}
|
||||
|
||||
fn key(key: Key, pressed: bool) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_checkbox_is_keyboard_operable_tab_to_it_then_space_or_enter_toggles_it() {
|
||||
for activate in [Key::Space, Key::Enter] {
|
||||
let ctx = egui::Context::default();
|
||||
tri_frame(&ctx, Some(false), vec![]);
|
||||
tri_frame(&ctx, Some(false), vec![key(Key::Tab, true), key(Key::Tab, false)]);
|
||||
assert!(ctx.memory(|m| m.focused()).is_some(), "Tab focuses the checkbox");
|
||||
tri_frame(&ctx, Some(false), vec![]);
|
||||
let clicked = tri_frame(&ctx, Some(false), vec![key(activate, true), key(activate, false)]);
|
||||
assert!(clicked, "{activate:?} on the focused checkbox toggles it");
|
||||
}
|
||||
}
|
||||
|
||||
/// The marks (paths, meshes, segments) painted inside the 14-point box of a lone checkbox.
|
||||
fn box_marks(state: Option<bool>) -> Vec<egui::Rect> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<egui::Rect>) {
|
||||
match s {
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } => out.push(s.visual_bounding_rect()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut o = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::widgets::checkbox_tri(ui, state, "Painted one");
|
||||
});
|
||||
o.textures_delta.clear();
|
||||
out.clear();
|
||||
o.shapes.iter().for_each(|c| walk(&c.shape, &mut out));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_checkbox_paints_a_dash_and_a_checked_one_a_tick() {
|
||||
// Icon::Minus is one flat bar; Icon::Check is a taller path.
|
||||
let mixed = box_marks(None);
|
||||
assert!(mixed.iter().any(|r| r.width() >= 5.0 && r.height() <= 2.5), "a flat dash is painted for the mixed state: {mixed:?}");
|
||||
let checked = box_marks(Some(true));
|
||||
assert!(checked.iter().any(|r| r.width() >= 5.0 && r.height() > 3.5), "a tick is painted for the checked state: {checked:?}");
|
||||
assert!(!checked.iter().any(|r| r.width() >= 5.0 && r.height() <= 2.5), "no dash on a checked box: {checked:?}");
|
||||
assert!(box_marks(Some(false)).is_empty(), "an empty box paints no mark");
|
||||
}
|
||||
@@ -59,6 +59,10 @@ pub struct Editor {
|
||||
pub(crate) anchor: Option<String>,
|
||||
/// The variable ids the table shows now, in row order (the range a Shift+click covers).
|
||||
pub(crate) order: Vec<String>,
|
||||
/// The variables the compact "Edit variables" editor is open on (empty = closed).
|
||||
bulk_edit: Vec<String>,
|
||||
/// That editor's tab: 0 Details, 1 Scope.
|
||||
bulk_tab: usize,
|
||||
}
|
||||
|
||||
/// A click on row `id`: plain selects only it, Shift extends from the anchor to it through the
|
||||
@@ -113,8 +117,13 @@ fn bulk_run(app: &mut FigApp, label: &str, vars: &[&Variable], step: impl Fn(&Va
|
||||
|
||||
/// The context menu of a row that is part of a selection of several: delete, move to a group and
|
||||
/// scope act on all of them.
|
||||
fn bulk_menu(app: &mut FigApp, ui: &mut Ui, coll_groups: &[String], vars: &[&Variable]) {
|
||||
fn bulk_menu(app: &mut FigApp, ui: &mut Ui, st: &mut Editor, coll_groups: &[String], vars: &[&Variable]) {
|
||||
let n = vars.len();
|
||||
if menu_item(ui, tr("Edit variables"), "") {
|
||||
st.bulk_edit = vars.iter().map(|v| v.id.clone()).collect();
|
||||
st.bulk_tab = 0;
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, &trn("Delete {n} variables", n, &[]), "") {
|
||||
bulk_run(app, "Delete variables", vars, |v| Some(("variable.delete", json!({"variable": v.id}))));
|
||||
ui.close();
|
||||
@@ -277,6 +286,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
});
|
||||
});
|
||||
details(app, ctx, &doc.variables, &mut st);
|
||||
bulk_details(app, ctx, &doc.variables, &mut st);
|
||||
app.ui.variables = open.then_some(st);
|
||||
}
|
||||
|
||||
@@ -848,7 +858,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if let Some(vars) = bulk_ids(view, st, v) {
|
||||
let coll_groups = groups(view.in_collection(&coll.id).map(|x| x.name.as_str()));
|
||||
bulk_menu(app, ui, &coll_groups, &vars);
|
||||
bulk_menu(app, ui, st, &coll_groups, &vars);
|
||||
return;
|
||||
}
|
||||
if menu_item(ui, tr("Edit variable"), "") {
|
||||
@@ -1178,9 +1188,115 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `f` holds for every variable (Some(true)), for none (Some(false)) or only some (None).
|
||||
/// With no variables nothing is on: Some(false), never a checked box for an empty list.
|
||||
fn tri<T>(vars: &[&T], f: impl Fn(&T) -> bool) -> Option<bool> {
|
||||
if vars.is_empty() {
|
||||
return Some(false);
|
||||
}
|
||||
let on = vars.iter().filter(|v| f(v)).count();
|
||||
if on == vars.len() {
|
||||
Some(true)
|
||||
} else if on == 0 {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The compact editor of several selected variables: Details (only Hide from publishing) and Scope
|
||||
/// (the scopes every selected variable's type has), each box mixed where the selection differs; a
|
||||
/// click sets or clears it on all of them in one undo step.
|
||||
fn bulk_details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
|
||||
if st.bulk_edit.is_empty() {
|
||||
return;
|
||||
}
|
||||
let list: Vec<&Variable> = st.bulk_edit.iter().filter_map(|id| vars.get_by_id(id)).filter(|v| v.extends.is_none()).collect();
|
||||
// The menu offers Edit variables only for two or more editable, non-inherited variables of a
|
||||
// file collection (`bulk_ids`), so this closes only when they have since gone (deleted,
|
||||
// undone) or a library was opened: there is nothing left to edit.
|
||||
if list.is_empty() || st.library.is_some() {
|
||||
st.bulk_edit.clear();
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
egui::Window::new(tr("Edit variables"))
|
||||
.id(Id::unique("variables_bulk_details"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(280.0)
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(16)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(260.0);
|
||||
if let Some(i) = widgets::segmented_text(ui, &[tr("Details"), tr("Scope")], st.bulk_tab) {
|
||||
st.bulk_tab = i;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if st.bulk_tab == 0 {
|
||||
let hidden = tri(&list, |v| v.hidden_from_publishing);
|
||||
if widgets::checkbox_tri(ui, hidden, tr("Hide from publishing")) {
|
||||
let to = hidden != Some(true);
|
||||
bulk_run(app, "Edit variables", &list, |v| Some(("variable.update", json!({"variable": v.id, "hiddenFromPublishing": to}))));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The scopes every selected variable's type has.
|
||||
let first = list.first().map_or(&[][..], |v| scopes_for(v.kind));
|
||||
let common: Vec<(&'static str, &'static str)> =
|
||||
first.iter().filter(|(id, _)| list.iter().all(|v| scopes_for(v.kind).iter().any(|(k, _)| k == id))).copied().collect();
|
||||
if common.is_empty() {
|
||||
widgets::label(ui, tr("No scope is shared by all the selected variables"), t.text_secondary);
|
||||
return;
|
||||
}
|
||||
let all = tri(&list, |v| v.scopes.is_none());
|
||||
if widgets::checkbox_tri(ui, all, tr("All supported properties")) {
|
||||
let to_all = all != Some(true);
|
||||
bulk_run(app, "Edit variables", &list, |v| {
|
||||
Some(("variable.update", json!({"variable": v.id, "scopes": if to_all { Value::Null } else { json!([]) }})))
|
||||
});
|
||||
}
|
||||
for (sid, label) in common {
|
||||
let state = tri(&list, |v| v.in_scope(sid));
|
||||
let clicked = ui
|
||||
.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
widgets::checkbox_tri(ui, state, tr(label))
|
||||
})
|
||||
.inner;
|
||||
if clicked {
|
||||
let to = state != Some(true);
|
||||
bulk_run(app, "Edit variables", &list, |v| {
|
||||
let next: Vec<&str> =
|
||||
scopes_for(v.kind).iter().map(|(id, _)| *id).filter(|id| if *id == sid { to } else { v.in_scope(id) }).collect();
|
||||
Some(("variable.update", json!({"variable": v.id, "scopes": next})))
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if !open {
|
||||
st.bulk_edit.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::groups;
|
||||
use super::{groups, tri};
|
||||
|
||||
#[test]
|
||||
fn tri_is_checked_empty_or_mixed() {
|
||||
let (a, b) = (true, false);
|
||||
assert_eq!(tri(&[&a, &a], |v| *v), Some(true));
|
||||
assert_eq!(tri(&[&b, &b], |v| *v), Some(false));
|
||||
assert_eq!(tri(&[&a, &b], |v| *v), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tri_of_no_variables_is_not_checked() {
|
||||
let none: [&bool; 0] = [];
|
||||
assert_eq!(tri(&none, |v| *v), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_include_parents_once_and_sorted() {
|
||||
|
||||
@@ -62,12 +62,12 @@ fn control_key_arrows_pan_with_nothing_selected() {
|
||||
(v.cx, v.cy)
|
||||
};
|
||||
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
|
||||
assert_eq!(view(&u), (10.0, 0.0), "20 screen pixels at 200%");
|
||||
assert_eq!(view(&u), (32.0, 0.0), "64 screen pixels at 200% are 32 document pixels");
|
||||
call(&mut u, "ui.key", json!({"key": "ArrowUp", "shift": true})).unwrap();
|
||||
assert_eq!(view(&u), (10.0, -100.0), "Shift: ten times as far");
|
||||
assert_eq!(view(&u), (32.0, -160.0), "Shift: 320 screen pixels, 160 document pixels");
|
||||
u.app.run("select.set", json!({"ids": [r]}));
|
||||
call(&mut u, "ui.key", json!({"key": "ArrowRight"})).unwrap();
|
||||
assert_eq!(view(&u), (10.0, -100.0), "with a selection the view stays");
|
||||
assert_eq!(view(&u), (32.0, -160.0), "with a selection the view stays");
|
||||
let id = figcraft_engine::params::id_of(&r).unwrap();
|
||||
assert_eq!(u.app.session.doc().unwrap().world_bounds(id).unwrap().x0, 1.0, "the selection moved instead");
|
||||
// With a shape tool chosen and nothing selected the arrows move the placement crosshair
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Which submenu the main and context menus show, driven through egui headless: a closed menu
|
||||
//! reopens with no submenu open (also one level down), hovering another row closes the open
|
||||
//! submenu at once while a submenu opens only once the pointer stays on its row a moment
|
||||
//! (sweeping across rows doesn't flash each one), and on the way from a
|
||||
//! submenu at once and a submenu opens on the first frame its row is hovered (as Figma's does),
|
||||
//! and on the way from a
|
||||
//! submenu's row to its submenu the rows the pointer crosses don't take over (the safe triangle),
|
||||
//! unless it rests on one or turns away.
|
||||
|
||||
@@ -214,7 +214,7 @@ fn resting_on_a_crossed_row_hands_over_to_it() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_straight_down_switches_after_a_short_hover() {
|
||||
fn moving_straight_down_switches_at_once() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
let file = u.top_row(0);
|
||||
@@ -227,24 +227,21 @@ fn moving_straight_down_switches_after_a_short_hover() {
|
||||
p.y = (p.y + 4.0).min(edit.y);
|
||||
u.hover(p);
|
||||
}
|
||||
assert!(u.tops_shown().is_empty(), "File closes at once, Edit waits: {:?}", u.tops_shown());
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["Edit"]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.tops_shown(), ["Edit"], "File closes at once, Edit opens on its first frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_submenu_opens_after_a_short_hover() {
|
||||
fn the_first_submenu_opens_on_the_next_frame() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.hover(u.top_row(0).center());
|
||||
u.frame(vec![]);
|
||||
assert!(u.tops_shown().is_empty(), "opened at once");
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["File"]);
|
||||
assert_eq!(u.tops_shown(), ["File"], "Figma opens it on the first frame (median 11 ms)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeping_across_rows_opens_only_where_the_pointer_stops() {
|
||||
fn sweeping_across_rows_shows_only_the_row_under_the_pointer() {
|
||||
let mut u = Ui::new();
|
||||
u.open_main_menu();
|
||||
u.settle(u.top_row(0).center());
|
||||
@@ -254,9 +251,8 @@ fn sweeping_across_rows_opens_only_where_the_pointer_stops() {
|
||||
u.hover(pos2(r.min.x + 20.0, r.center().y));
|
||||
// (A panel drawn above the row the pointer is on goes the next frame.)
|
||||
u.frame(vec![]);
|
||||
assert!(u.tops_shown().is_empty(), "{:?} open on row {i}", u.tops_shown());
|
||||
assert_eq!(u.tops_shown(), [TOPS[i]], "wrong submenu open on row {i}");
|
||||
}
|
||||
u.wait(HOVER_DELAY + 0.05);
|
||||
assert_eq!(u.tops_shown(), ["Arrange"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ pub(crate) fn submenu<R>(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: i
|
||||
// Which submenu is open: hover intent with a safe triangle.
|
||||
//
|
||||
// Every menu tree (the main menu, the context menu) keeps, per level, which of its submenus is
|
||||
// open. Hovering another row closes the open submenu at once; resting on a submenu row for
|
||||
// `HOVER_DELAY` opens it (sweeping across rows doesn't flash every submenu on the way). Except while the
|
||||
// open. Hovering a row closes the open submenu at once and opens the row's own submenu on the
|
||||
// same frame (as Figma's main menu does, measured; `HOVER_DELAY` is zero). Except while the
|
||||
// pointer is on its way from the open submenu's row to the submenu: while each move lands inside
|
||||
// the triangle between where it was and the submenu's near edge (it keeps getting nearer), rows
|
||||
// it crosses don't take over and the submenu's row stays highlighted. Resting (`AIM_REST`) or
|
||||
@@ -20,8 +20,10 @@ pub(crate) fn submenu<R>(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: i
|
||||
// last submenu open.
|
||||
// Closing a menu tree forgets all of it, so it reopens with no submenu open.
|
||||
|
||||
/// How long the pointer stays on a submenu row before its submenu opens.
|
||||
pub(crate) const HOVER_DELAY: f64 = 0.15;
|
||||
/// How long the pointer stays on a submenu row before its submenu opens. None: Figma's main menu
|
||||
/// opens it on the first frame (median 11 ms measured), so the delay is zero; kept as a constant so
|
||||
/// the pending-row bookkeeping the aim relies on stays as it was.
|
||||
pub(crate) const HOVER_DELAY: f64 = 0.0;
|
||||
|
||||
/// How long the pointer may rest inside the safe triangle before the row under it takes over.
|
||||
pub(crate) const AIM_REST: f64 = 0.3;
|
||||
|
||||
@@ -210,6 +210,7 @@ pub(crate) fn field_row<R>(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f
|
||||
#[cfg(test)]
|
||||
mod variable_tests {
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, RawInput};
|
||||
use figcraft_doc::WrapStyle;
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
@@ -223,6 +224,8 @@ mod variable_tests {
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
text: NodeId,
|
||||
/// The AccessKit nodes of the last frame.
|
||||
nodes: Vec<egui::accesskit::Node>,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
@@ -236,7 +239,8 @@ mod variable_tests {
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({ "ids": [text] })).unwrap();
|
||||
let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text };
|
||||
let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text, nodes: Vec::new() };
|
||||
p.ctx.enable_accesskit();
|
||||
crate::theme::install_fonts(&p.ctx, false, 0);
|
||||
crate::theme::apply(&p.ctx, Tokens::light());
|
||||
for _ in 0..3 {
|
||||
@@ -269,6 +273,9 @@ mod variable_tests {
|
||||
crate::variable_ui::picker(&mut self.app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
if let Some(update) = out.platform_output.accesskit_update.take() {
|
||||
self.nodes = update.nodes.into_iter().map(|(_, n)| n).collect();
|
||||
}
|
||||
let mut texts = Vec::new();
|
||||
for c in out.shapes {
|
||||
walk(&c.shape, &mut texts);
|
||||
@@ -351,4 +358,75 @@ mod variable_tests {
|
||||
assert!(shows(&texts, "Inter") && shows(&texts, "Bold"), "the dropdowns show the last values");
|
||||
assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error);
|
||||
}
|
||||
|
||||
/// The centre of the AccessKit node labelled `label` (a button, say).
|
||||
fn node_center(p: &Panel, label: &str) -> Pos2 {
|
||||
let n = p.nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no node {label:?}"));
|
||||
let r = n.bounds().expect("bounds");
|
||||
Pos2::new(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)
|
||||
}
|
||||
|
||||
const WRAP: [&str; 3] = [
|
||||
"Auto: as many words as fit on each line",
|
||||
"Balance: every line close to the same length",
|
||||
"Pretty: full lines, no one word alone on the last",
|
||||
];
|
||||
|
||||
/// The Wrap style buttons that are on, by short name.
|
||||
fn wrap_on(p: &Panel) -> Vec<&'static str> {
|
||||
let on = |label: &str| p.nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no node {label:?}")).toggled();
|
||||
["Auto", "Balance", "Pretty"].into_iter().zip(WRAP).filter(|(_, l)| on(l) == Some(egui::accesskit::Toggled::True)).map(|(n, _)| n).collect()
|
||||
}
|
||||
|
||||
/// The panel with the Type settings popover open, over two paragraphs.
|
||||
fn settings_panel() -> Panel {
|
||||
let mut p = Panel::new();
|
||||
p.app.session.execute("node.set", &json!({"id": p.text, "props": {"characters": "one\ntwo"}})).unwrap();
|
||||
p.frame(vec![]);
|
||||
let at = node_center(&p, "Type settings");
|
||||
p.click(at);
|
||||
p.frame(vec![]);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_control_follows_the_caret_paragraph() {
|
||||
// #1827: with the caret in one paragraph the control shows and sets that paragraph's style.
|
||||
let mut p = settings_panel();
|
||||
assert_eq!(wrap_on(&p), ["Auto"], "the layer's style when not editing");
|
||||
figcraft_engine::textedit::begin(&mut p.app.session, p.text, Some(5), false).unwrap();
|
||||
p.frame(vec![]);
|
||||
assert_eq!(wrap_on(&p), ["Auto"]);
|
||||
let at = node_center(&p, WRAP[1]);
|
||||
p.click(at);
|
||||
let td = |p: &Panel| p.app.session.doc().unwrap().node(p.text).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!((td(&p).para_wrap_style(0), td(&p).para_wrap_style(1)), (WrapStyle::Auto, WrapStyle::Balance), "only the caret's paragraph");
|
||||
assert_eq!(td(&p).wrap_style, WrapStyle::Auto);
|
||||
assert_eq!(wrap_on(&p), ["Balance"], "the control shows the paragraph's value");
|
||||
figcraft_engine::textedit::set_caret(&mut p.app.session, 1, false).unwrap();
|
||||
p.frame(vec![]);
|
||||
assert_eq!(wrap_on(&p), ["Auto"], "another paragraph shows its own");
|
||||
assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_style_control_has_nothing_active_for_mixed_paragraphs() {
|
||||
let mut p = settings_panel();
|
||||
figcraft_engine::textedit::begin(&mut p.app.session, p.text, Some(5), false).unwrap();
|
||||
p.frame(vec![]);
|
||||
let at = node_center(&p, WRAP[2]);
|
||||
p.click(at);
|
||||
assert_eq!(wrap_on(&p), ["Pretty"]);
|
||||
// A selection across both paragraphs, which differ: none is on.
|
||||
figcraft_engine::textedit::set_caret(&mut p.app.session, 1, false).unwrap();
|
||||
figcraft_engine::textedit::set_caret(&mut p.app.session, 6, true).unwrap();
|
||||
p.frame(vec![]);
|
||||
assert!(wrap_on(&p).is_empty(), "{:?}", wrap_on(&p));
|
||||
// Choosing one sets both paragraphs.
|
||||
let at = node_center(&p, WRAP[1]);
|
||||
p.click(at);
|
||||
assert_eq!(wrap_on(&p), ["Balance"]);
|
||||
let t = p.app.session.doc().unwrap().node(p.text).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (WrapStyle::Balance, WrapStyle::Balance));
|
||||
}
|
||||
}
|
||||
@@ -388,7 +388,14 @@ fn type_settings(
|
||||
}
|
||||
// Where lines break when the text wraps (Explore text properties).
|
||||
caption(ui, tr("Wrap style"));
|
||||
let wrap = common(doc, ids, |n| n.kind.text().map(|t| t.wrap_style as usize)).flatten();
|
||||
// Per paragraph, as the text direction: while editing, the selected paragraphs.
|
||||
let wrap = common(doc, ids, |n| {
|
||||
n.kind.text().and_then(|t| {
|
||||
let (a, b) = range.filter(|r| r.0 == n.id).map_or((0, t.char_count()), |(_, a, b)| (a, b));
|
||||
t.common_wrap_style(a, b).map(|w| w as usize)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let items = [
|
||||
(tr("Auto"), tr_noop("Auto: as many words as fit on each line")),
|
||||
(tr("Balance"), tr_noop("Balance: every line close to the same length")),
|
||||
@@ -396,7 +403,7 @@ fn type_settings(
|
||||
];
|
||||
if let Some(i) = text_seg(ui, "wrap", &items, wrap, w) {
|
||||
let style = ["auto", "balance", "pretty"].get(i).copied().unwrap_or("auto");
|
||||
app.set_props(ids, json!({"wrapStyle": style}));
|
||||
app.run("text.wrapStyle", json!({"ids": ids, "style": style}));
|
||||
}
|
||||
// The paragraphs' base direction, once a right-to-left script is in the text or a direction
|
||||
// is forced ("controls are available to override text direction per paragraph", Add
|
||||
|
||||
@@ -44,13 +44,13 @@ fn arrows_pan_the_view_when_nothing_is_selected() {
|
||||
u.app.run("select.set", json!({"ids": []}));
|
||||
u.app.run("view.set", json!({"zoom": 2.0, "cx": 0, "cy": 0}));
|
||||
press(&mut u, Key::ArrowRight, Modifiers::NONE);
|
||||
assert_eq!(view(&u), (10.0, 0.0, 2.0), "20 screen pixels at 200%");
|
||||
assert_eq!(view(&u), (32.0, 0.0, 2.0), "64 screen pixels at 200% are 32 document pixels");
|
||||
press(&mut u, Key::ArrowUp, Modifiers::SHIFT);
|
||||
assert_eq!(view(&u), (10.0, -100.0, 2.0), "Shift: ten times as far");
|
||||
assert_eq!(view(&u), (32.0, -160.0, 2.0), "Shift: 320 screen pixels, 160 document pixels");
|
||||
// With a selection the arrows nudge it and the view stays.
|
||||
u.app.run("select.set", json!({"ids": [r]}));
|
||||
press(&mut u, Key::ArrowRight, Modifiers::NONE);
|
||||
assert_eq!(view(&u), (10.0, -100.0, 2.0));
|
||||
assert_eq!(view(&u), (32.0, -160.0, 2.0));
|
||||
assert_eq!(u.app.session.doc().unwrap().world_bounds(r).unwrap().x0, 1.0);
|
||||
}
|
||||
|
||||
|
||||
@@ -250,3 +250,175 @@ fn the_bulk_menu_scrolls_when_forty_groups_make_it_taller_than_the_window() {
|
||||
assert!(visible(&shapes), "scrolling the menu never brought {last:?} into view");
|
||||
assert!(screen.contains_rect(popup_rect(&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();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
for (n, scopes) in
|
||||
[("bravo", json!(["FRAME_FILL", "STROKE_COLOR"])), ("charlie", json!(["FRAME_FILL", "STROKE_COLOR"])), ("delta", json!(["FRAME_FILL"]))]
|
||||
{
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": n, "type": "color", "value": "#ff0000"})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": n, "scopes": scopes})).unwrap();
|
||||
}
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.variables = Some(Default::default());
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
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");
|
||||
click(u, text_center(&menu, "Edit variables"), PointerButton::Primary, Modifiers::NONE);
|
||||
flat(&u.frame_output(vec![]).shapes)
|
||||
}
|
||||
|
||||
fn flags(u: &Ui) -> Vec<(bool, bool)> {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
["bravo", "charlie", "delta"]
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let v = doc.variables.variables.iter().find(|v| v.name == *n).unwrap();
|
||||
(v.hidden_from_publishing, v.in_scope("STROKE_COLOR"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_variables_sets_stroke_on_all_selected_in_one_undo_step() {
|
||||
let mut u = colour_window();
|
||||
let shapes = open_edit_variables(&mut u);
|
||||
assert!(has_text(&shapes, "Hide from publishing") && has_text(&shapes, "Scope"));
|
||||
click(&mut u, text_center(&shapes, "Scope"), PointerButton::Primary, Modifiers::NONE);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(has_text(&shapes, "Stroke"));
|
||||
assert_eq!(flags(&u), [(false, true), (false, true), (false, false)]);
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
click(&mut u, text_center(&shapes, "Stroke"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert_eq!(flags(&u), [(false, true), (false, true), (false, true)]);
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
u.app.session.undo().unwrap();
|
||||
assert_eq!(flags(&u), [(false, true), (false, true), (false, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_variables_hides_all_selected_from_publishing_in_one_undo_step() {
|
||||
let mut u = colour_window();
|
||||
let shapes = open_edit_variables(&mut u);
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
click(&mut u, text_center(&shapes, "Hide from publishing"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert!(flags(&u).iter().all(|f| f.0), "all hidden");
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
u.app.session.undo().unwrap();
|
||||
assert!(flags(&u).iter().all(|f| !f.0));
|
||||
}
|
||||
|
||||
/// Bravo, charlie and delta of the given types (a value fitting each).
|
||||
fn typed_window(types: [&str; 3]) -> Ui {
|
||||
let mut s = Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
for (n, t) in ["bravo", "charlie", "delta"].into_iter().zip(types) {
|
||||
let value = match t {
|
||||
"number" => json!(1),
|
||||
"boolean" => json!(true),
|
||||
"string" => json!("x"),
|
||||
_ => json!("#ff0000"),
|
||||
};
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": n, "type": t, "value": value})).unwrap();
|
||||
}
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.variables = Some(Default::default());
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
fn scope_tab(u: &mut Ui) -> Vec<egui::Shape> {
|
||||
let shapes = open_edit_variables(u);
|
||||
click(u, text_center(&shapes, "Scope"), PointerButton::Primary, Modifiers::NONE);
|
||||
flat(&u.frame_output(vec![]).shapes)
|
||||
}
|
||||
|
||||
fn scopes(u: &Ui) -> Vec<Option<Vec<String>>> {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
["bravo", "charlie", "delta"].iter().map(|n| doc.variables.variables.iter().find(|v| v.name == *n).unwrap().scopes.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_supported_properties_turns_on_for_every_selected_variable_and_off_again_one_undo_step_each() {
|
||||
let mut u = colour_window();
|
||||
let shapes = scope_tab(&mut u);
|
||||
assert!(scopes(&u).iter().all(|s| s.is_some()), "each variable starts limited");
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
click(&mut u, text_center(&shapes, "All supported properties"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert!(scopes(&u).iter().all(|s| s.is_none()), "on: no variable is limited: {:?}", scopes(&u));
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
click(&mut u, text_center(&shapes, "All supported properties"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert!(scopes(&u).iter().all(|s| s.as_ref().is_some_and(|l| l.is_empty())), "off: hidden from pickers: {:?}", scopes(&u));
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 2, "one undo step each");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_checked_scope_clears_it_on_every_selected_variable_in_one_undo_step() {
|
||||
let mut u = colour_window();
|
||||
let shapes = scope_tab(&mut u);
|
||||
let before = u.app.session.undo_labels(100).len();
|
||||
// Frame fill is on for all three: checked, so the click clears it.
|
||||
click(&mut u, text_center(&shapes, "Frame fill"), PointerButton::Primary, Modifiers::NONE);
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(["bravo", "charlie", "delta"].iter().all(|n| !doc.variables.variables.iter().find(|v| v.name == *n).unwrap().in_scope("FRAME_FILL")));
|
||||
assert_eq!(flags(&u), [(false, true), (false, true), (false, false)], "the other scopes stay");
|
||||
assert_eq!(u.app.session.undo_labels(100).len(), before + 1, "one undo step");
|
||||
u.app.session.undo().unwrap();
|
||||
assert!(scopes(&u).iter().all(|s| s.as_ref().is_some_and(|l| l.iter().any(|x| x == "FRAME_FILL"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_share_no_scope_and_the_scope_tab_says_so() {
|
||||
let mut u = typed_window(["boolean", "boolean", "boolean"]);
|
||||
let shapes = scope_tab(&mut u);
|
||||
assert!(has_text(&shapes, "No scope is shared by all the selected variables"));
|
||||
assert!(!has_text(&shapes, "All supported properties"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_types_list_only_the_scopes_every_type_has() {
|
||||
// Number and string share only Text content; a colour shares nothing with them.
|
||||
let mut u = typed_window(["number", "number", "string"]);
|
||||
let shapes = scope_tab(&mut u);
|
||||
assert!(has_text(&shapes, "Text content"), "the shared scope is shown");
|
||||
for other in ["Corner radius", "Font family", "Font size"] {
|
||||
assert!(!has_text(&shapes, other), "{other} is not common");
|
||||
}
|
||||
assert!(!has_text(&shapes, "No scope is shared by all the selected variables"));
|
||||
let mut u = typed_window(["number", "color", "string"]);
|
||||
let shapes = scope_tab(&mut u);
|
||||
assert!(has_text(&shapes, "No scope is shared by all the selected variables"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_variables_is_not_offered_when_every_selected_variable_is_inherited() {
|
||||
let mut u = window();
|
||||
u.app.session.execute("variable.extendCollection", &json!({"collection": "Theme"})).unwrap();
|
||||
u.app.ui.variables = Some(crate::dialogs::variables_panel::Editor::on(Some("Theme (extended)".into())));
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
click_row(&mut u, "bravo", Modifiers::NONE);
|
||||
click_row(&mut u, "delta", SHIFT);
|
||||
assert_eq!(selected(&u), ["bravo", "charlie", "delta"]);
|
||||
let p = row(&mut u, "delta");
|
||||
click(&mut u, p, PointerButton::Secondary, Modifiers::NONE);
|
||||
let menu = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(!has_text(&menu, "Edit variables"), "inherited variables are edited in their own collection");
|
||||
}
|
||||
@@ -1295,13 +1295,19 @@ pub fn paint_swatch(painter: &egui::Painter, rect: Rect, paint: &figcraft_color:
|
||||
|
||||
/// A small checkbox with label.
|
||||
pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool {
|
||||
checkbox_tri(ui, Some(on), label)
|
||||
}
|
||||
|
||||
/// A checkbox that can show a mixed state (`None`: a dash, for a setting that differs across a
|
||||
/// selection); returns whether it was clicked.
|
||||
pub fn checkbox_tri(ui: &mut Ui, state: Option<bool>, label: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(200.0), FIELD_H), Sense::click());
|
||||
a11y::checkbox(&resp, label, on);
|
||||
a11y::checkbox_tri(&resp, label, state);
|
||||
let b = Rect::from_min_size(pos2(rect.min.x, rect.center().y - 7.0), vec2(14.0, 14.0));
|
||||
if on {
|
||||
if state != Some(false) {
|
||||
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
|
||||
icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on);
|
||||
icons::paint(ui.painter(), b.shrink(1.5), if state.is_some() { Icon::Check } else { Icon::Minus }, t.accent_on);
|
||||
} else {
|
||||
ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_color::{Paint, PaintKind};
|
||||
use figcraft_doc::{
|
||||
Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num,
|
||||
Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, WrapStyle, trim_num,
|
||||
};
|
||||
use figcraft_engine::library;
|
||||
use serde_json::{Value, json};
|
||||
@@ -1002,6 +1002,11 @@ fn paragraph_rows(ui: &mut Ui, ps: &ParagraphStyle) {
|
||||
ListStyle::Bulleted => rows.push(tr("Bulleted list").into()),
|
||||
ListStyle::Numbered => rows.push(tr("Numbered list").into()),
|
||||
}
|
||||
match ps.wrap_style {
|
||||
WrapStyle::Auto => {}
|
||||
WrapStyle::Balance => rows.push(format!("{}: {}", tr("Wrap style"), tr("Balance"))),
|
||||
WrapStyle::Pretty => rows.push(format!("{}: {}", tr("Wrap style"), tr("Pretty"))),
|
||||
}
|
||||
if !ps.features.is_empty() {
|
||||
rows.push(ps.features.iter().map(|(tag, on)| format!("{}{tag}", if *on { '+' } else { '-' })).collect::<Vec<_>>().join(" "));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boards parity
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-10 · **Change:** minor (a `.jam` file's stickies come in as native board stickies, with their colour, text and author kept and the size as saved (#1808); a sticky's text stays 18 and the note grows taller instead of the text shrinking, as measured in FigJam's web app (#2157); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-10 · **Change:** minor (resizing a board table by height changes its rows, not blank space below them: a bottom or top edge grows the last row, a corner shares the change over every row, and a table keeps at least 40 per cell, measured in FigJam's web app, 2026-11-09 (#2172); a `.jam` file's stickies come in as native board stickies, with their colour, text and author kept and the size as saved (#1808); a sticky's text stays 18 and the note grows taller instead of the text shrinking, as measured in FigJam's web app (#2157); mind maps, first step: a first node with three children on curved branches, laid out again after every edit (`board.mindMap`, `board.mindMapAdd`, #2226); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
|
||||
|
||||
**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind
|
||||
(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that
|
||||
@@ -60,7 +60,7 @@ all end with the same geometry.
|
||||
| Voting results: who voted for what, earlier sessions' results | partial | once a vote ends, its results list who voted for each object and how many times (by name in a live session, kept with the results so someone who has left still shows by name), as the reference app shows how each participant voted; each ended vote's results are kept in the file (the last 20), listed newest first in the Results popover with arrows to earlier ones, and deleted one at a time (`board.vote {action: delete, index}`; Clear results only takes them off the board), as the reference app keeps each session's results until deleted; saved, synced in live sessions, and like the vote not undo steps. Anyone who can edit the board ends or clears a vote, as the reference app's help describes. A vote has no prompt (question) of its own |
|
||||
| Board widgets | missing | design-file widgets exist (`crates/engine/src/extensions/widgets.rs`), not wired to boards |
|
||||
| Templates (retrospective, brainstorm, flow chart…) | partial | five built-in templates drawn up for FigCraft (retrospective, brainstorm, weekly planner, flow chart, kanban board), placed from the toolbar's templates picker (each with a picture of itself) or `board.template {name, x?, y?}` as ordinary sections, stickies, shapes, connectors and text in one undo step; `board.templates` lists them. No community templates, no saving your own |
|
||||
| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text |
|
||||
| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; resizing a table by its bottom edge or its height grows the last row and a corner drag shares the change equally over every row and column (a side drag changes no row), the table keeps hugging its rows and a cell keeps at least 40 each way (measured in FigJam's web app, 2026-11-09: its floor is about 52, FigCraft's is 40, and its right edge grows only the last column); a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No per-column widths yet (a width change splits equally), no column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text |
|
||||
| Mind maps | partial | from commands only so far (#2226): `board.mindMap {x?, y?, text?, children?}` places a first node (bold, no box) with three children on its right, joined by curved branches; `board.mindMapAdd {id?, relation?: child\|sibling}` adds a node. Nodes are text that grows wider as it is typed and taller with line breaks; a node's children stand in one column 88 units right of the widest node of its own column, 48 apart, centred on it, and the map lays itself out again in the normalize pass after every edit, undo and live-session change; moving the first node moves the map; a deleted node's children stay where they are without their branch. Branches are board connectors (`branch`: stroke 4, #757575, round caps), so their style, end points, weight and colour change like any connector's. No toolbar entry, shortcuts, "+" handles, dragging a node onto another, left side or `.jam` import yet |
|
||||
| Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files |
|
||||
| Audio and huddles | missing | |
|
||||
@@ -72,6 +72,7 @@ all end with the same geometry.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | minor | M12, #2172: a board table's height change goes into its rows. Measured in FigJam's web app, 2026-11-09 (a table has no lock-proportions control and never scales proportionally): a bottom-edge drag grows only the last row, a corner drag shares the change equally over every row and column, text keeps its size, and a table stops shrinking at about 52 per cell with a row never shorter than its wrapped text. FigCraft already split a width change equally (every cell fills); the bug was height: a bottom or corner resize turned the table from hugging to fixed height, so the rows stayed at 40 with blank space below them (or were clipped when shrunk) and typing no longer grew the table. `board_table::settle_resize`, called first in the normalize pass, finds a table left at a fixed height, puts the change into the rows' minimum height (the last row, or every row when both axes changed), raises a width that is too narrow to `columns x 40`, and returns the table to hugging, so every route (drag, `node.set`, `transform.resize`, MCP, live-session peers) gives the same result, in one undo step. FigCraft's floor is 40 (FigJam's is about 52) and there are no per-column widths yet: a side drag of the right edge still splits equally where FigJam grows only the last column. Numbers unchanged. Tests: `board_tables_tests` (the bottom, corner and side drags, the floor, typing after a resize and undo, live sessions, junk). |
|
||||
| 2026-11-09 | patch | M12, #2157: a sticky's text is 18 and stays 18 (measured in FigJam's web app: ten lines, then a long paragraph, never changed it; the note kept its width and grew taller). The normalize pass no longer resets a sticky's text to a size by its length (24 down to 11 before), so stickies in saved files keep their size. Size presets (Small to Huge) are still missing. Tests: `a_new_sticky_starts_at_18`, `a_sticky_keeps_its_text_size_as_text_is_added`, `a_sticky_saved_with_another_text_size_keeps_it` |
|
||||
| 2026-11-10 | minor | M12, #2226 (step 1 of 4): mind maps in the document and engine. A mind map node is a board object of its own (`BoardItem::MindNode`: text with no box) and a branch is a connector marked `branch`; the map's shape is read from its branches. `board.mindMap` places a first node with three children and `board.mindMapAdd` adds a child or a sibling, each one undo step. The normalize pass lays a touched map out again from its first node (`board::settle_mind_maps`, `figcraft_doc::mind_layout`): 88 between columns, 48 between siblings, each stack centred on its parent, numbers from a FigJam file saved and decoded. Tests: `board_mindmap_tests` in doc (five) and engine (eight). Numbers unchanged (no toolbar entry yet). |
|
||||
| 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. |
|
||||
|
||||
@@ -62,7 +62,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) |
|
||||
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style, the wrap style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829; not the wrap style yet: its `.fig` field is unknown, #1827) |
|
||||
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
|
||||
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
|
||||
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style (a face name from a string variable, or a numeric weight from a number variable, which picks the nearest face and a variable font's `wght` axis), size, line height and letter spacing, the rest through the command; a bound line height is applied in pixels, as the reference tool does (measured 2026-11-10) |
|
||||
@@ -81,10 +81,12 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | minor | M3, #2082: Edit variables in the context menu of a selection of several local variables opens the reference's compact editor with two tabs: Details holds only Hide from publishing, Scope the scopes every selected variable's type has (All supported properties and one box per scope; a selection that shares none says so). Each box is checked when all selected variables have it, empty when none, and shows a dash when they differ; a click sets it on all (or clears it when checked on all), each change one `command.batch`, so one undo step. Names, descriptions and code syntax stay single-variable, as in the reference. The Scope: menu entries stay. A tri-state `widgets::checkbox_tri` draws the dash. Tests: `variables_select_tests` (headless UI). Grade unchanged |
|
||||
| 2026-11-10 | minor | M7, #2174: a text style binds a number variable to its weight (`style.bind {field: "fontWeight"}`): the style takes the face nearest the number and sets the `wght` axis, so static and variable fonts both follow; a face-name (string) binding and a weight binding replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones. A number bound to a line height is applied in pixels, as the reference tool does, which a test now pins. Numbers unchanged. Tests: `style_vars_tests`, `style_picker::tests`. |
|
||||
| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. |
|
||||
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
|
||||
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
|
||||
| 2026-11-07 | minor | M4, #1827: text styles carry the wrap style (`wrapStyle` in a style's `paragraph`): applied with the style (on a range, to the paragraphs touched), moved by `style.update`, unlinking when edited; shown read-only in the style editor |
|
||||
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
|
||||
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. |
|
||||
| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged |
|
||||
|
||||
+11
-5
@@ -298,6 +298,11 @@ Or in a project's `.mcp.json`:
|
||||
`textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline
|
||||
down to an underline's top edge), `textDecorationSkipInk` (underlines break around descenders) and
|
||||
`textDecorationColor` (a colour, or `auto` for the text's) are character props too.
|
||||
- **Wrap style** (`auto`, `balance`, `pretty`) works at three levels: `wrapStyle` on `node.set`
|
||||
sets the layer (every paragraph); with `start`/`end` it sets the paragraphs those chars touch,
|
||||
and a paragraph's own style beats the layer's. `text.wrapStyle` sets the selected paragraphs while
|
||||
text is edited. `get_node` reports one `wrapStyle`, or `lineWrapStyles` per paragraph when they
|
||||
differ.
|
||||
- **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the
|
||||
nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a
|
||||
range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations`
|
||||
@@ -313,10 +318,11 @@ Or in a project's `.mcp.json`:
|
||||
`style.detach {which: "text", start?, end?}` unlinks them (values stay). A layer's `styles.text` is
|
||||
the style all its chars share (none when they differ); `get_node` lists the linked chars as
|
||||
`textStyleRanges` (`[start, end, style id]`).
|
||||
A text style also carries paragraph spacing and indent, the list style and the OpenType features
|
||||
(`paragraph` beside `style` in `style.list`): applying it sets them (on a range, the list goes to
|
||||
the paragraphs touched; the rest is the layer's), `style.update` moves them too, and a `.fig`
|
||||
file keeps them with the style.
|
||||
A text style also carries paragraph spacing and indent, the list style, the wrap style and the
|
||||
OpenType features (`paragraph` beside `style` in `style.list`; a `value` takes `wrapStyle`):
|
||||
applying it sets them (on a range, the list and wrap style go to the paragraphs touched; the rest
|
||||
is the layer's), `style.update` moves them too, and a `.fig` file keeps them with the style
|
||||
(not the wrap style yet).
|
||||
Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis
|
||||
values; at the paragraph level the spacing, indent, list and features) on linked chars unlinks
|
||||
them; `style.update` moves every linked char. `style.create {type: "text", name}` takes the
|
||||
@@ -638,7 +644,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
|
||||
`board.nextSticky` (`Shift+Cmd/Ctrl+Enter`, also while typing in a sticky). Keys no design-file
|
||||
shortcut uses; on a design file they refuse.
|
||||
- `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same)
|
||||
pan the view by 20 screen pixels a step, 200 with `big` (Shift).
|
||||
pan the view by 64 screen pixels a step, 320 with `big` (Shift).
|
||||
- `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session.
|
||||
- `view.outlinesHiddenLayers {on?}` and `view.outlinesObjectBounds {on?}`: what outline mode
|
||||
(`view.outlines`, `view.outlinesSelection`) draws besides the shapes: hidden layers, and the box
|
||||
|
||||
+7
-4
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Flatten bakes a vector's point corner radius into its path (#2195); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Flatten bakes a vector's point corner radius into its path (#2195); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+ShifLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -25,7 +25,7 @@ interaction feel not yet compared.
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other; a command that needs parameters only through its menu item, and none twice under one label, #1574), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's |
|
||||
| Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 or the keyboard's Menu key opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard (`layers_keyboard_tests`) |
|
||||
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). A menu or submenu that would run past the window's bottom moves up to fit (never above the top), and one taller than the window scrolls with the wheel, the keyboard's focus kept in view; the context menu's submenus share this (#2217, `menu_overflow_tests`). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open on the first frame their row is hovered (a click or the keyboard opens one at once; Figma's main menu measured 2026-11-07 with synthetic pointer events: median 11 ms to open, 13 ms to switch rows) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; the open delay is measured; the safe triangle and its 0.3 s rest are still tuned by feel, and Figma's main menu showed no triangle at all in that measurement, so the aim is kept pending the maintainer's decision). A menu or submenu that would run past the window's bottom moves up to fit (never above the top), and one taller than the window scrolls with the wheel, the keyboard's focus kept in view; the context menu's submenus share this (#2217, `menu_overflow_tests`). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place |
|
||||
| Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover |
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
|
||||
| Multiplayer avatars, Share button | missing | waits for multiplayer |
|
||||
@@ -98,7 +98,7 @@ interaction feel not yet compared.
|
||||
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
|
||||
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
|
||||
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
|
||||
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. At three levels (the text layer, a paragraph and a text style, per Explore text properties): the paragraph level is the caret's or the selection's paragraphs while editing (`text.wrapStyle`; the control follows them), or `start`/`end` on `node.set wrapStyle` (a paragraph's own style beats the layer's; setting the layer sets every paragraph again; Enter carries it to the new paragraph; inspect reports `lineWrapStyles` when paragraphs differ and CSS `text-wrap` only when they agree); the text-style level is a style's `wrapStyle` (shown read-only in the style editor, applied with the style, moved by `style.update`, unlinking when edited). The `.fig` import/export mapping stays open (#1827): a `.fig` export leaves the wrap styles out and a `.fig` import reads every text as Auto, so the wrap styles are lost through a `.fig` round trip. Test: `text_behaviour_tests::wrap_style_per_paragraph_overrides_the_layers`, `styles_behaviour_tests::text_styles_carry_the_wrap_style`, `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
|
||||
| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Developer Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` |
|
||||
| Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` |
|
||||
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) |
|
||||
@@ -112,7 +112,7 @@ interaction feel not yet compared.
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
|
||||
| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) |
|
||||
| Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only |
|
||||
| Frame labels (rotated, right-to-left), size badge | done | |
|
||||
| Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on |
|
||||
@@ -190,11 +190,13 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | patch | View: the arrow keys with nothing selected pan 64 px, 320 with Shift (five steps), as measured in Figma's web app at 100% zoom (62–64 px in two trials, about 320 px with Shift, about ±3 px); they panned 20 px (200 with Shift). Test: `cmd::view::start_on_home_tests::the_arrows_pan_64_px_and_320_with_shift_as_measured_in_figma` |
|
||||
| 2026-11-09 | minor | Shortcuts: Shift+T picks the new Annotation tool (`tool.annotation {id?}`, Accessibility at Figma, Add an annotation): with a layer selected, or an `id`, it asks the UI for that layer's note at once; in the tool a press on a layer selects it and asks, a press on empty canvas does nothing; the Inspect tab opens with the caret in the new-note field and the engine writes nothing itself; documented shortcuts 137, bound 136 (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet |
|
||||
| 2026-11-11 | patch | M5, gap 4: marquee selection as measured in Figma's web app (Select layers and objects, Selection marquee): a partly covered top-level frame or section gives the layers in it the marquee touches (it was selected only when enclosed, so nothing came), an empty frame is selected on touch, a rotated shape is touched by its outline and not its box, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole), and also takes the top-level layers it touches outside it, and selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole, not its layers). Flipped: `marquee_selects_what_it_touches`, `cmd_marquee_selects_nested_layers`, `a_box_selects_like_a_marquee`. Tests: `marquee_behaviour_tests`. Counts unchanged |
|
||||
| 2026-11-11 | patch | #2195: Flatten on a vector whose points have a corner radius bakes the rounding into the path, as measured in Figma web: each rounded point becomes the arc's end points joined by curves (a 3-point V with radius 60 on the apex gives 4 points), the layer stays a vector, every radius reads 0 afterwards and the box shrinks to the real geometry (245 to 205.09 high). Shapes' own radii were already baked. Numbers unchanged. |
|
||||
| 2026-11-08 | minor | Vector edit mode, #1875, measured in Figma (web, real keys): Copy with segments selected takes only those (with just a point selected, the segments touching it) and a paste outside edit mode is a new vector layer in place; Cut removes them, the path splitting into subpaths in the same layer with edit mode staying on, and a paste outside gives a new layer with the cut segments; Paste while editing a vector adds the clipboard's segments, or a copied layer's outline, to the same network in place, selecting the pasted points, with no new layer and the bounds growing. FigCraft copied the whole layer, Cut left nothing to paste and Paste did nothing. `edit.copy`, `edit.cut` and `edit.paste` act so with no `ids`/`parent` given (Ctrl+X is the real Cut again; Shift+Delete still heals); the copy carries the layer's look (fills, strokes, effects, opacity, blend; the width profile only when every segment is copied); layers with no outline are skipped. Tests: `vector_clipboard_tests` |
|
||||
| 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first |
|
||||
| 2026-11-07 | patch | Main menu: a submenu opens on the first frame its row is hovered instead of after 0.15 s (Figma's main menu measured with synthetic pointer events and 1 ms polling: median 11 ms to open, 13 ms to switch rows, no hover delay); the safe-triangle aim is unchanged, though the same measurement saw no aim logic in Figma, left for the maintainer to decide |
|
||||
| 2026-11-10 | patch | M5, #1560: locked layers follow Figma's rules (Lock and unlock layers; measured in Figma's web app): a locked layer selected in the Layers panel ignores the arrow keys and the nudge commands while its properties still edit, and a child of a locked layer keeps its padlock inert (the Layers panel padlock, Lock/Unlock and `object.toggleLock` without a value no longer lock the child on its own) |
|
||||
| 2026-11-10 | patch | #2217: the main menu's submenus (Object, say) ran past the window's bottom, behind the taskbar, with the lowest commands out of reach, because submenus were not constrained to the window (to keep them beside their row). Every menu panel now scrolls when taller than the window less 8 pt margins, and a submenu is held within the window's height (moved up, never above the top) while keeping its place beside its row, so menu-aim is unchanged. Tests: `menu_overflow_tests.rs`. Numbers unchanged. |
|
||||
| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |
|
||||
@@ -217,6 +219,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. |
|
||||
| 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. |
|
||||
| 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Developer Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |
|
||||
| 2026-11-07 | minor | M4, #1827: wrap style per paragraph and in text styles, overriding the layer's: `ParaList::wrap` (None follows the layer; Enter carries it, an override equal to the layer's is none), `text.wrapStyle` while editing and `node.set wrapStyle` with `start`/`end` set the paragraphs, without them the layer sets every paragraph; `restyle_breaks` takes a style per paragraph; inspect `lineWrapStyles`, CSS `text-wrap` when the paragraphs agree; `ParagraphStyle::wrap_style` (text styles apply, update and unlink on it); the Wrap style control follows the paragraph; the `.fig` mapping stays open |
|
||||
| 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` |
|
||||
| 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` |
|
||||
| 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. |
|
||||
|
||||
Reference in new issue
Block a user