Merge main into #2241

This commit is contained in:
bflatastic
2026-11-12 11:40:47 +07:00
24 changed files with 1513 additions and 30 deletions
+184
View File
@@ -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
+66
View File
@@ -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)))
+3
View File
@@ -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"
+9 -1
View File
@@ -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,
});
});
}
+77
View File
@@ -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");
}
+2
View File
@@ -302,6 +302,8 @@ impl FigApp {
self.ui.narrow = narrow;
// A narrow window shows the right panel when its button asks for it, a board's too.
let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right);
// A highlighted fill, stroke or effect row goes with the panel it's in, before it draws.
crate::property_rows::reconcile(self, &ctx, right_open);
// A right-to-left UI language mirrors the window: the "left" rail and panel go on the
// right, the "right" (Design) panel on the left. Their ids and widths stay as they are.
let mirrored = self.ui.mirrored;
+119 -3
View File
@@ -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() {
+9
View File
@@ -232,6 +232,9 @@ mod present_tests;
mod present_view_tests;
#[cfg(test)]
mod property_menu_tests;
mod property_rows;
#[cfg(test)]
mod property_rows_tests;
#[cfg(test)]
mod proto_editor_tests;
#[cfg(test)]
@@ -597,6 +600,10 @@ pub struct UiState {
/// copy changes the engine clipboard without writing the OS clipboard; the generation then
/// no longer matches, so the stale mirror is not mistaken for the user's own copy.
pub last_copy_generation: u64,
/// The fill, stroke or effect row highlighted in the Design panel: Cmd/Ctrl+C copies it.
pub property_row: Option<property_rows::Highlight>,
/// A paint or effect copied from its row: Cmd/Ctrl+V pastes it while it's the last copy.
pub property_copy: Option<property_rows::Copied>,
/// A save shortcut pressed while a panel field had the keyboard: the field lets go (which
/// commits what was typed) and the command runs next frame for that document/window.
pub after_field: Option<shortcuts::AfterFieldSave>,
@@ -740,6 +747,8 @@ impl Default for UiState {
last_copy: None,
last_copy_generation: 0,
property_row: None,
property_copy: None,
after_field: None,
held: egui::Modifiers::NONE,
next_autosave: 0.0,
+4
View File
@@ -193,6 +193,10 @@ pub fn invoke(app: &mut FigApp, id: &str) {
app.toast(tr("Properties copied"));
}
}
// A fill, stroke or effect row highlighted in the Design panel: that paint or effect.
"edit.copy" if crate::property_rows::current(app).is_some() => {
crate::property_rows::copy_highlighted(app);
}
"edit.copy" | "edit.cut" => {
let ids = app.session.selection();
// For other apps: text layers' text, other layers as SVG; taken before a cut removes them.
+6 -1
View File
@@ -16,7 +16,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>)
if app.control_synthetic {
// Synthetic input is user-like: Dev Mode refuses the paste here too, and only the engine's
// own copied layers are pasted (the system clipboard is never read).
if !dev_mode_refuses(app, "edit.paste") {
if !(at.is_none() && crate::property_rows::paste(app)) && !dev_mode_refuses(app, "edit.paste") {
app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y})));
}
return;
@@ -89,6 +89,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>)
return paste_text(app, &text, (cx, cy), at.is_none());
}
}
// Nothing newer from another app: a fill, stroke or effect copied from its row, when that was
// the last copy, else our copied layers.
if at.is_none() && crate::property_rows::paste(app) {
return;
}
if !dev_mode_refuses(app, "edit.paste") {
app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y})));
}
+13 -1
View File
@@ -75,8 +75,19 @@ impl ListDrag {
/// Item `index` (model order) was laid out with its main row at `header`, covering `extent`
/// (the header plus anything open under it). Its grip starts a drag carrying `label`.
pub fn row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) {
self.grip_row(ui, index, header, extent, Sense::drag(), label);
}
/// [`Self::row`] whose grip also takes a click, the way a click beside the row's fields
/// highlights a fill, stroke or effect row: `true` when it was clicked. The grip senses the
/// click without being focusable, so Tab doesn't stop on it.
pub fn row_clicked(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, label: impl FnOnce() -> String) -> bool {
self.grip_row(ui, index, header, extent, Sense::DRAG | Sense::CLICK, label)
}
fn grip_row(&mut self, ui: &mut Ui, index: usize, header: Rect, extent: Rect, sense: Sense, label: impl FnOnce() -> String) -> bool {
let grip = Rect::from_min_max(header.min, pos2(header.min.x + PAD, header.max.y));
let resp = ui.interact(grip, grip_of(self.list, index), Sense::drag());
let resp = ui.interact(grip, grip_of(self.list, index), sense);
// The handle shows while the pointer is over the row (or the row is being dragged).
let shown = match self.dragging {
Some(d) => d == index,
@@ -93,6 +104,7 @@ impl ListDrag {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
self.row_by(&resp, index, extent, label);
resp.clicked()
}
/// Item `index` laid out over `extent`, dragged by the whole row (`resp` senses drags; the
+14 -4
View File
@@ -1,9 +1,10 @@
use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect, row_target};
use super::{
Blur, BlurType, Document, Effect, EffectKind, FieldEdit, FigApp, Glass, GridAlign, GridKind, Icon, Id, LayoutGrid, Lead, ListDrag,
MAX_BLUR_RADIUS, NodeId, Noise, NoiseMode, Paint, Sense, Shadow, StyleSlot, Texture, Ui, VariableType, common, icons, json, move_flags,
moved_index, remove_flags, removed_index, style_picker, tr, trf, trn, vec2, widgets,
};
use crate::property_rows::PropertyKind;
/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an
/// open picker and settings flags aligned when rows move or are removed.
@@ -29,9 +30,12 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
let Some(list) = list else { return };
let mut drag = ListDrag::new(ui, "effects");
let mut removed = None;
let selected_row = crate::theme::Tokens::get(ui.ctx()).selected_row;
for (i, e) in list.iter().enumerate().rev() {
let mut open = effect_open(ui.ctx(), i);
let header = row_rect(ui, 32.0, |ui| {
// A click on the row's background highlights it for Cmd/Ctrl+C (`property_rows`).
let highlight = crate::property_rows::fill(app, PropertyKind::Effect, i, selected_row);
let header = row_target(ui, 32.0, highlight, |ui| {
if icons::button(ui, Icon::Effects, 24.0, open, tr("Effect settings")).clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(Id::unique((FX_OPEN, i)), open));
@@ -63,10 +67,12 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
removed = Some(i);
}
});
crate::property_rows::row(app, &header, PropertyKind::Effect, i, &ids);
let header = header.rect;
let can_paste = app.session.clipboard.effect.is_some();
row_context_menu(ui, header, Id::unique(("effect_menu", i))).show(|ui| {
if menu_item(ui, tr("Copy effect"), "") {
app.run("effect.copy", json!({"ids": ids, "index": i}));
crate::property_rows::copy(app, PropertyKind::Effect, i, &ids);
ui.close();
}
if can_paste && menu_item(ui, tr("Paste effect"), "") {
@@ -77,10 +83,13 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
if open {
effect_settings(app, ui, &ids, i, e);
}
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || tr(e.label()).to_string());
if drag.row_clicked(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || tr(e.label()).to_string()) {
crate::property_rows::click(app, PropertyKind::Effect, i, &ids, header);
}
}
if let Some((from, to)) = drag.finish(ui) {
app.run("effect.move", json!({"ids": ids, "from": from, "to": to}));
crate::property_rows::follow(app, PropertyKind::Effect, from, Some(to));
move_flags(ui, FX_OPEN, list.len(), from, to);
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
*index = moved_index(*index, from, to);
@@ -88,6 +97,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
}
// Open settings and an open colour picker stay with their effects, or go with theirs.
if let Some(i) = removed {
crate::property_rows::follow(app, PropertyKind::Effect, i, None);
remove_flags(ui, FX_OPEN, list.len(), i);
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
match removed_index(*index, i) {
+20 -1
View File
@@ -155,12 +155,31 @@ pub(crate) fn row_rect(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) -> Rect {
pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect {
let w = ui.available_width();
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
lay_out(ui, r, inset_y, add);
r
}
/// A [`row_rect`] that is a pointer target itself: a click on its background (beside and between
/// its controls, which are on top of it) comes back in the response. It senses clicks without
/// being focusable, so Tab and F6 still go from control to control. `fill` is drawn under the
/// controls (a highlighted fill, stroke or effect row).
pub(crate) fn row_target(ui: &mut Ui, h: f32, fill: Option<Color32>, add: impl FnOnce(&mut Ui)) -> egui::Response {
let w = ui.available_width();
let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::CLICK);
if let Some(fill) = fill {
ui.painter().rect_filled(r, CornerRadius::ZERO, fill);
}
lay_out(ui, r, (h - FIELD_H) / 2.0, add);
resp
}
/// Add a row's controls in `r`, on the section's grid, inset `inset_y` from its top and bottom.
fn lay_out(ui: &mut Ui, r: Rect, inset_y: f32, add: impl FnOnce(&mut Ui)) {
// The section's grid: 16 in from the left, 8 from the right (not 16 on both sides).
let inner = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + inset_y), pos2(r.max.x - crate::widgets::PAD_RIGHT, r.max.y - inset_y));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)));
child.spacing_mut().item_spacing = vec2(crate::widgets::GUTTER, 0.0);
add(&mut child);
r
}
/// Width of the check column of [`menu_icon_item`] (the mark, or nothing, so labels line up).
+24 -13
View File
@@ -1,10 +1,11 @@
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_rect};
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, preview_blend, row, row_context_menu, row_target};
use super::stroke::stroke_settings;
use super::{
Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, Paint,
PaintKind, Rect, Sense, Stroke, StrokeAlign, StyleSlot, Tip, Tokens, Ui, Value, VariableType, common, icons, json, moved_index, pos2, regular,
removed_index, style_picker, tr, trf, vec2, widgets,
};
use crate::property_rows::PropertyKind;
/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and
/// the blend-mode row.
@@ -341,10 +342,13 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" });
for (i, p) in l.iter().enumerate().rev() {
let r = paint_row(app, ui, &ids, i, p, fills, region);
drag.row(ui, i, r, r, || paint_label(p));
if drag.row_clicked(ui, i, r, r, || paint_label(p)) {
crate::property_rows::click(app, if fills { PropertyKind::Fill } else { PropertyKind::Stroke }, i, &ids, r);
}
}
if let Some((from, to)) = drag.finish(ui) {
app.run(if fills { "fill.move" } else { "stroke.move" }, json!({"ids": ids, "from": from, "to": to}));
crate::property_rows::follow(app, if fills { PropertyKind::Fill } else { PropertyKind::Stroke }, from, Some(to));
// An open colour picker keeps editing the paint it was on.
if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
&& *f == fills
@@ -451,12 +455,15 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
/// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area.
/// One paint's row; `region`: a row of the regions selected in vector edit mode (#1875), which
/// leaves out what belongs to the layer (its variable binding, the image crop).
/// leaves out what belongs to the layer (its variable binding, the image crop). A click on its
/// background highlights it for Cmd/Ctrl+C ([`crate::property_rows`]).
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool, region: bool) -> Rect {
let t = Tokens::get(ui.ctx());
let ids = ids.to_vec();
let cmd = if fills { "fill.set" } else { "stroke.set" };
let r = row_rect(ui, 32.0, |ui| {
let kind = if fills { PropertyKind::Fill } else { PropertyKind::Stroke };
let highlight = crate::property_rows::fill(app, kind, i, t.selected_row);
let resp = row_target(ui, 32.0, highlight, |ui| {
let w = ui.available_width() - 60.0;
let (sresp, e) = widgets::paint_field(ui, Id::unique(("swatch", fills, i)), Id::unique(("pop", fills, i)), p, w, |child, text_r| {
let r = child.max_rect();
@@ -537,24 +544,28 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
if toggle {
app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible}));
}
if remove
&& app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some()
&& let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
&& *f == fills
{
if remove && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some() {
// A highlighted row stays on its paint, or goes with it.
crate::property_rows::follow(app, kind, i, None);
// An open colour picker keeps editing the paint it was on, or closes with it.
match removed_index(*index, i) {
Some(k) => *index = k,
None => app.ui.picker = None,
if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
&& *f == fills
{
match removed_index(*index, i) {
Some(k) => *index = k,
None => app.ui.picker = None,
}
}
}
});
crate::property_rows::row(app, &resp, kind, i, &ids);
let r = resp.rect;
let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") };
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills);
row_context_menu(ui, r, Id::unique(("paint_menu", fills, i))).show(|ui| {
if menu_item(ui, copy, "") {
app.run(if fills { "fill.copy" } else { "stroke.copy" }, json!({"ids": ids, "index": i}));
crate::property_rows::copy(app, kind, i, &ids);
ui.close();
}
if can_paste && menu_item(ui, paste, "") {
+254
View File
@@ -0,0 +1,254 @@
//! Fill, stroke and effect rows of the Design panel as things to copy, as the help center's "Copy
//! and paste properties between layers" describes: click to highlight the property in the right
//! sidebar, press Cmd/Ctrl+C, select another layer, press Cmd/Ctrl+V.
//!
//! A click on a row's background (beside and between its controls, or on its reorder grip)
//! highlights it, one row at a time. The highlight belongs to the selection it was made on, and
//! goes with Escape or a press on the canvas or elsewhere in the Design panel. Cmd/Ctrl+C then
//! copies that one paint or effect (`fill.copy`, `stroke.copy`, `effect.copy`, as the row's menu
//! does) instead of the layers, and Cmd/Ctrl+V pastes it onto every selected layer (`fill.paste`,
//! `stroke.paste`, `effect.paste`) while it is still the last thing copied: on top of their list,
//! or in place of their highlighted row when it is of the same kind. A copy keeps its kind: a
//! stroke never lands in the fills, whatever row is highlighted.
//!
//! A programmatic copy (the control channel) never writes the system clipboard, so it has no
//! mirror there: a programmatic paste takes it from the engine clipboard, while a user's paste
//! goes by what the system clipboard holds, as it does for copied layers.
use egui::{Color32, Context, Rect, Response};
use figcraft_doc::NodeId;
use serde_json::json;
use crate::{FigApp, RightTab};
/// Which list of the Design panel a row belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PropertyKind {
Fill,
Stroke,
Effect,
}
impl PropertyKind {
/// The command that copies row `index` of this list.
fn copy_command(self) -> &'static str {
match self {
PropertyKind::Fill => "fill.copy",
PropertyKind::Stroke => "stroke.copy",
PropertyKind::Effect => "effect.copy",
}
}
/// The command that pastes the copied paint or effect.
fn paste_command(self) -> &'static str {
match self {
PropertyKind::Fill => "fill.paste",
PropertyKind::Stroke => "stroke.paste",
PropertyKind::Effect => "effect.paste",
}
}
/// What the system clipboard gets when the copy has no text of its own: the marker that also
/// stands for copied layers, so a paste never makes a text layer of it.
fn marker(self) -> &'static str {
match self {
PropertyKind::Fill => "figcraft://clipboard/fill",
PropertyKind::Stroke => "figcraft://clipboard/stroke",
PropertyKind::Effect => "figcraft://clipboard/effect",
}
}
/// Whether the engine clipboard still holds a copy of this kind.
fn on_clipboard(self, app: &FigApp) -> bool {
let clip = &app.session.clipboard;
match self {
PropertyKind::Fill => clip.paint.as_ref().is_some_and(|(fills, _)| *fills),
PropertyKind::Stroke => clip.paint.as_ref().is_some_and(|(fills, _)| !*fills),
PropertyKind::Effect => clip.effect.is_some(),
}
}
}
/// The row highlighted in the Design panel. It belongs to the layers selected when it was clicked
/// and goes when they are no longer the selection.
#[derive(Clone, Debug, PartialEq)]
pub struct Highlight {
pub kind: PropertyKind,
/// The paint's or effect's index in its list (model order: the top one is the last).
pub index: usize,
/// The selection the row was highlighted on.
pub ids: Vec<NodeId>,
/// The file it was highlighted in.
pub doc: u64,
/// Where the row was drawn this frame (`None` until it draws): a press elsewhere lets it go,
/// and so does a frame it isn't drawn in.
pub rect: Option<Rect>,
}
/// A paint or effect copied from its row: Cmd/Ctrl+V pastes it rather than the copied layers while
/// it is the last thing copied.
#[derive(Clone, Debug, PartialEq)]
pub struct Copied {
pub kind: PropertyKind,
/// The engine clipboard's layers generation at the copy: a layer copy since moves it on.
pub layers_generation: u64,
/// What the copy put on the system clipboard (`None`: a programmatic copy, which never writes
/// it). A user's paste takes the property only while the system clipboard is still that.
pub mirror: Option<String>,
}
/// The highlighted row, when it still belongs to the selection in the Design panel.
pub(crate) fn current(app: &FigApp) -> Option<&Highlight> {
let h = app.ui.property_row.as_ref()?;
let ds = app.session.ds().ok()?;
(app.ui.right_tab == RightTab::Design && ds.uid == h.doc && ds.selection == h.ids).then_some(h)
}
/// Let the highlight go once it no longer belongs to what the Design panel shows (another file or
/// tab, another selection, the panel not `shown`), or when the pointer is pressed on the canvas or
/// elsewhere in the Design panel. A press on its own row keeps it; so does one on a menu, a popup
/// or the toolbar, so Edit ▸ Copy still copies the highlighted property. Called each frame before
/// the right panel draws its rows.
pub(crate) fn reconcile(app: &mut FigApp, ctx: &Context, shown: bool) {
if app.ui.property_row.is_none() {
return;
}
let pressed = ctx.input(|i| if i.pointer.any_pressed() { i.pointer.press_origin() } else { None });
// A menu or popup is a foreground area over whatever is under it.
let lets_go = pressed.filter(|p| ctx.layer_id_at(*p).is_none_or(|l| l.order != egui::Order::Foreground)).is_some_and(|p| {
let on_row = app.ui.property_row.as_ref().and_then(|h| h.rect).is_some_and(|r| r.contains(p));
let in_panel = app.ui.right_panel_rect.is_some_and(|r| r.contains(p));
!on_row && (in_panel || app.canvas.rect.contains(p))
});
// The row draws itself each frame (`row`): one that wasn't drawn last frame is gone (an undo
// took its paint away, say), and its index may now name another row or none.
let drawn = app.ui.property_row.as_ref().is_some_and(|h| h.rect.is_some());
if lets_go || !shown || !drawn || current(app).is_none() {
app.ui.property_row = None;
} else if let Some(h) = app.ui.property_row.as_mut() {
h.rect = None;
}
}
/// Escape with a highlighted row: the highlight goes and the selection stays. `false` when no row
/// is highlighted (Escape does what it does otherwise).
pub(crate) fn escape(app: &mut FigApp) -> bool {
let highlighted = current(app).is_some();
app.ui.property_row = None;
highlighted
}
/// Row `index` of the `kind` list, drawn as `resp` (a row target, see [`fill`]) for the selection
/// `ids`: a click on its background highlights it; the highlighted row keeps where it was drawn.
pub(crate) fn row(app: &mut FigApp, resp: &Response, kind: PropertyKind, index: usize, ids: &[NodeId]) {
if resp.clicked() {
click(app, kind, index, ids, resp.rect);
} else if let Some(h) = app.ui.property_row.as_mut()
&& h.kind == kind
&& h.index == index
{
h.rect = Some(resp.rect);
}
}
/// A click on row `index` of the `kind` list (drawn at `rect`) for the selection `ids`, on its
/// background or on its reorder grip, the strip left of its fields: it is the highlighted row now.
pub(crate) fn click(app: &mut FigApp, kind: PropertyKind, index: usize, ids: &[NodeId], rect: Rect) {
let Some(doc) = app.session.ds().ok().map(|d| d.uid) else { return };
app.ui.property_row = Some(Highlight { kind, index, ids: ids.to_vec(), doc, rect: Some(rect) });
}
/// The background of row `index` of the `kind` list: the selected-row colour when it is the
/// highlighted one.
pub(crate) fn fill(app: &FigApp, kind: PropertyKind, index: usize, selected: Color32) -> Option<Color32> {
current(app).is_some_and(|h| h.kind == kind && h.index == index).then_some(selected)
}
/// The highlight follows its paint or effect when the rows of `kind` are reordered (`from` moved
/// to `to`) or one is removed (`to` is `None`); it goes with a removed one.
pub(crate) fn follow(app: &mut FigApp, kind: PropertyKind, from: usize, to: Option<usize>) {
let Some(h) = app.ui.property_row.as_mut().filter(|h| h.kind == kind) else { return };
let moved = match to {
Some(to) => Some(figcraft_engine::cmd::lists::moved_index(h.index, from, to)),
None => figcraft_engine::cmd::lists::removed_index(h.index, from),
};
match moved {
Some(i) => h.index = i,
None => app.ui.property_row = None,
}
}
/// Cmd/Ctrl+C with a highlighted row: copy its paint or effect. `false` when no row is
/// highlighted (the layers are copied as usual).
pub(crate) fn copy_highlighted(app: &mut FigApp) -> bool {
let Some(h) = current(app).cloned() else { return false };
copy(app, h.kind, h.index, &h.ids);
true
}
/// Copy paint or effect `index` of the `kind` list of `ids` (a highlighted row, or the row's menu)
/// and remember it as the last thing copied. A solid colour goes to the system clipboard as its
/// hex code, as other apps read colours; anything else as a marker of the copy.
pub(crate) fn copy(app: &mut FigApp, kind: PropertyKind, index: usize, ids: &[NodeId]) {
let Some(copied) = app.run(kind.copy_command(), json!({"ids": ids, "index": index})) else { return };
let layers_generation = app.session.clipboard.layers_generation;
let mirror = if app.control_synthetic {
// The engine's copy is no longer what the system clipboard mirrors: a user's paste must
// not take that text for this copy (`menus::paste` would paste stale layers).
if app.ui.property_copy.as_ref().is_some_and(|c| c.mirror.is_some() && c.mirror == app.ui.last_copy) {
app.ui.last_copy = None;
}
None
} else {
let text = match kind {
PropertyKind::Effect => None,
PropertyKind::Fill | PropertyKind::Stroke => {
serde_json::from_value::<figcraft_color::Paint>(copied).ok().and_then(|p| p.solid_color()).map(figcraft_color::Color::to_css_hex)
}
}
.unwrap_or_else(|| kind.marker().to_string());
app.clipboard_set_text(&text);
// The system clipboard holds our copy now: a paste reads it as ours (`menus::paste`).
app.ui.last_copy = Some(text.clone());
app.ui.last_copy_generation = layers_generation;
Some(text)
};
app.ui.property_copy = Some(Copied { kind, layers_generation, mirror });
}
/// Cmd/Ctrl+V when nothing newer than our own copy is on the system clipboard: when a paint or
/// effect copied from its row is the last thing copied, paste it onto the selected layers (in
/// place of their highlighted row of that kind, else on top) and return `true`; `false` leaves
/// the paste to the copied layers.
pub(crate) fn paste(app: &mut FigApp) -> bool {
let Some(c) = app.ui.property_copy.clone() else { return false };
let still_last = c.layers_generation == app.session.clipboard.layers_generation
&& c.kind.on_clipboard(app)
// A user's paste: the system clipboard must still be what this copy put there. A
// programmatic paste reads only the engine clipboard.
&& (app.control_synthetic || (c.mirror.is_some() && app.ui.last_copy == c.mirror));
if !still_last {
// No layers were copied since this copy, but the engine's copy of it is gone (a command
// copied a paint or effect of another kind since): nothing to paste, rather than the
// layers copied before it. For a user's paste, only while the system clipboard still
// holds this copy's mirror; a programmatic paste reads only the engine clipboard.
let ours = app.control_synthetic
|| (c.mirror.is_some() && app.ui.last_copy == c.mirror && app.ui.last_copy_generation == app.session.clipboard.layers_generation);
return ours && c.layers_generation == app.session.clipboard.layers_generation;
}
let ids = app.session.selection();
if ids.is_empty() {
// Nothing to paste onto: a paste of the layers copied before would be surprising.
return true;
}
let cmd = c.kind.paste_command();
if crate::menus::dev_mode_refuses(app, cmd) {
return true;
}
let mut params = json!({"ids": ids});
if let Some(h) = current(app).filter(|h| h.kind == c.kind) {
params["index"] = json!(h.index);
}
app.run(cmd, params);
true
}
+513
View File
@@ -0,0 +1,513 @@
//! A fill, stroke or effect row of the Design panel highlights on a click on its background, and
//! the keyboard copies and pastes it ("Copy and paste properties between layers": click to
//! highlight the property in the right sidebar, Cmd/Ctrl+C, select another layer, Cmd/Ctrl+V).
use std::cell::RefCell;
use std::rc::Rc;
use egui::{Event, Modifiers, PointerButton, Pos2, pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::Services;
use crate::layers_drag_tests::Ui;
use crate::live_tests::{flat, text_center};
use crate::property_rows::{Highlight, PropertyKind};
/// The test machine's system clipboard: the text last written, which another app may replace.
struct Clip(Rc<RefCell<Option<String>>>);
impl Services for Clip {
fn clipboard_set_text(&mut self, s: &str) {
*self.0.borrow_mut() = Some(s.to_string());
}
fn clipboard_get_text(&mut self) -> Option<String> {
self.0.borrow().clone()
}
}
/// A 40 x 40 rectangle filled with `color`; its id.
fn rect(s: &mut Session, x: f64, color: &str) -> NodeId {
let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap());
s.execute("fill.set", &json!({"ids": [id], "color": color})).unwrap();
id
}
/// Rectangles A (red, a 3 pt blue stroke and a layer blur) and B (green, a drop shadow), A
/// selected, in a window whose system clipboard the test reads and writes.
fn setup() -> (Ui, NodeId, NodeId, Rc<RefCell<Option<String>>>) {
let mut s = Session::new();
let a = rect(&mut s, 0.0, "#FF0000");
let b = rect(&mut s, 100.0, "#00FF00");
s.execute("stroke.add", &json!({"ids": [a], "paint": {"type": "solid", "color": "#0000FF"}})).unwrap();
s.execute("node.set", &json!({"ids": [a], "props": {"strokeWeight": 3.0}})).unwrap();
s.execute("effect.add", &json!({"ids": [a], "type": "layerBlur"})).unwrap();
s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut u = Ui::new(s);
let clip = Rc::new(RefCell::new(None));
u.app.services = Box::new(Clip(clip.clone()));
u.frame(vec![]);
(u, a, b, clip)
}
/// A primary click at `p`, and a few frames after it.
fn click(u: &mut Ui, p: Pos2) {
u.frame(vec![Event::PointerMoved(p)]);
for pressed in [true, false] {
u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
}
u.frame(vec![]);
}
/// Click the background of the Design panel row showing `label`: its left margin, where no
/// control is (the reorder grip shows there on hover).
fn click_row(u: &mut Ui, label: &str) {
let shapes = flat(&u.frame_output(vec![]).shapes);
let y = text_center(&shapes, label).y;
let panel = u.app.ui.right_panel_rect.expect("the Design panel is open");
click(u, pos2(panel.min.x + 6.0, y));
}
/// Cmd/Ctrl+C, as the desktop shell delivers it.
fn copy(u: &mut Ui) {
u.frame(vec![Event::Copy]);
}
/// Cmd/Ctrl+V with the system clipboard's text, as the desktop shell delivers it.
fn paste(u: &mut Ui, clip: &Rc<RefCell<Option<String>>>) {
let text = clip.borrow().clone().unwrap_or_default();
u.frame(vec![Event::Paste(text)]);
}
/// Select `id` only, and draw the panel for it.
fn select(u: &mut Ui, id: NodeId) {
u.app.run("select.set", json!({"ids": [id]}));
u.frame(vec![]);
}
/// The hex codes of `id`'s fills, bottom to top.
fn fills(u: &Ui, id: NodeId) -> Vec<String> {
let doc = u.app.session.doc().unwrap();
doc.node(id).unwrap().fills.iter().map(|p| p.solid_color().unwrap().to_hex()).collect()
}
#[test]
fn a_click_on_a_fill_rows_background_highlights_it_and_the_keyboard_copies_and_pastes_it() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "FF0000");
assert_eq!(
u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index, h.ids.clone())),
Some((PropertyKind::Fill, 0, vec![a])),
"the row is highlighted for A"
);
let layers_before = u.app.session.clipboard.layers_generation;
copy(&mut u);
let (fills_flag, paint) = u.app.session.clipboard.paint.clone().expect("the fill is on the clipboard");
assert!(fills_flag);
assert_eq!(paint, u.app.session.doc().unwrap().node(a).unwrap().fills[0]);
assert_eq!(u.app.session.clipboard.layers_generation, layers_before, "the layers were not copied");
assert!(u.app.session.clipboard.nodes.is_empty());
assert_eq!(clip.borrow().as_deref(), Some("#FF0000"), "the colour goes to the system clipboard as its hex code");
// Another layer selected, the paste puts the fill on top of its own.
select(&mut u, b);
assert!(u.app.ui.property_row.is_none(), "the highlight belonged to A");
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00", "FF0000"]);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "no layer was pasted");
assert_eq!(fills(&u, a), ["FF0000"], "A keeps its fill");
// One undo step takes it back off.
u.app.run("edit.undo", json!({}));
assert_eq!(fills(&u, b), ["00FF00"]);
}
#[test]
fn a_paste_replaces_the_highlighted_row_of_the_target() {
let (mut u, _, b, clip) = setup();
click_row(&mut u, "FF0000");
copy(&mut u);
select(&mut u, b);
click_row(&mut u, "00FF00");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Fill, 0)));
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["FF0000"], "in place of B's highlighted fill");
}
#[test]
fn a_stroke_row_copies_its_paint_and_settings() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "0000FF");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.kind), Some(PropertyKind::Stroke));
copy(&mut u);
select(&mut u, b);
paste(&mut u, &clip);
let doc = u.app.session.doc().unwrap();
let (na, nb) = (doc.node(a).unwrap(), doc.node(b).unwrap());
assert_eq!(nb.strokes, na.strokes);
assert_eq!(nb.stroke.weight, 3.0, "the weight comes with the stroke");
assert_eq!(fills(&u, b), ["00FF00"], "the fills are untouched");
}
#[test]
fn an_effect_row_copies_and_pastes_its_effect() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "Layer blur");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Effect, 0)));
copy(&mut u);
assert_eq!(clip.borrow().as_deref(), Some("figcraft://clipboard/effect"), "an effect has no text: a marker of our copy");
select(&mut u, b);
paste(&mut u, &clip);
let doc = u.app.session.doc().unwrap();
let blur = doc.node(a).unwrap().effects[0].clone();
assert_eq!(doc.node(b).unwrap().effects.len(), 2);
assert_eq!(doc.node(b).unwrap().effects[1], blur, "the blur goes on top of B's shadow");
}
#[test]
fn without_a_highlighted_row_the_keyboard_copies_and_pastes_layers() {
let (mut u, _, b, clip) = setup();
copy(&mut u);
assert!(u.app.session.clipboard.paint.is_none(), "no fill was copied");
assert_eq!(u.app.session.clipboard.nodes.len(), 1, "A was copied");
select(&mut u, b);
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "a copy of A was pasted");
assert_eq!(fills(&u, b), ["00FF00"]);
}
#[test]
fn copying_layers_after_a_fill_pastes_the_layers() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "FF0000");
copy(&mut u);
// Another selection lets the highlight go; the next copy takes the layers.
select(&mut u, b);
select(&mut u, a);
assert!(u.app.ui.property_row.is_none(), "selecting A again doesn't bring the highlight back");
copy(&mut u);
select(&mut u, b);
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "the layers were the last copy");
assert_eq!(fills(&u, b), ["00FF00"]);
}
#[test]
fn text_copied_in_another_app_afterwards_pastes_as_text() {
let (mut u, _, b, clip) = setup();
click_row(&mut u, "FF0000");
copy(&mut u);
select(&mut u, b);
*clip.borrow_mut() = Some("Hello".into());
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00"], "the fill isn't what the clipboard holds now");
assert_eq!(u.app.session.doc().unwrap().node_count(), layers + 1, "the text became a text layer");
}
#[test]
fn a_press_elsewhere_lets_the_highlight_go() {
let (mut u, a, _, _) = setup();
click_row(&mut u, "FF0000");
assert!(u.app.ui.property_row.is_some());
// A press on the row's own background keeps it.
click_row(&mut u, "FF0000");
assert!(u.app.ui.property_row.is_some());
// One anywhere else in the panel lets it go, the selection staying as it is.
let shapes = flat(&u.frame_output(vec![]).shapes);
click(&mut u, text_center(&shapes, "Effects"));
assert_eq!(u.app.session.selection(), [a]);
assert!(u.app.ui.property_row.is_none());
// With nothing highlighted Cmd/Ctrl+C copies the layer again.
copy(&mut u);
assert!(u.app.session.clipboard.paint.is_none());
}
#[test]
fn the_highlight_follows_its_row_when_rows_move_or_go() {
let (mut u, a, _, _) = setup();
let doc = u.app.session.ds().unwrap().uid;
let at = |u: &Ui| u.app.ui.property_row.as_ref().map(|h| h.index);
u.app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 1, ids: vec![a], doc, rect: None });
crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 1, Some(3));
assert_eq!(at(&u), Some(3), "moved with its paint");
crate::property_rows::follow(&mut u.app, PropertyKind::Stroke, 3, None);
assert_eq!(at(&u), Some(3), "another list's change leaves it");
crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 0, None);
assert_eq!(at(&u), Some(2), "a paint under it went");
crate::property_rows::follow(&mut u.app, PropertyKind::Fill, 2, None);
assert_eq!(at(&u), None, "its paint went");
}
/// A key press with no modifiers, as the desktop shell delivers it.
fn key(u: &mut Ui, key: egui::Key) {
u.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
u.frame(vec![Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]);
}
#[test]
fn escape_lets_the_highlight_go_and_keeps_the_selection() {
let (mut u, a, _, _) = setup();
click_row(&mut u, "FF0000");
assert!(u.app.ui.property_row.is_some());
key(&mut u, egui::Key::Escape);
assert!(u.app.ui.property_row.is_none(), "Escape let the highlight go");
assert_eq!(u.app.session.selection(), [a], "and only that: A is still selected");
// The next Escape deselects, as it does with no row highlighted.
key(&mut u, egui::Key::Escape);
assert!(u.app.session.selection().is_empty());
}
#[test]
fn a_click_on_the_canvas_lets_the_highlight_go() {
let (mut u, a, _, _) = setup();
u.app.run("view.zoomFit", json!({}));
u.frame(vec![]);
click_row(&mut u, "FF0000");
assert!(u.app.ui.property_row.is_some());
// A click on A itself: the selection stays as it is, the highlight goes.
let on_a = u.app.to_screen(20.0, 20.0);
assert!(u.app.canvas.rect.contains(on_a), "A is in view");
click(&mut u, on_a);
assert_eq!(u.app.session.selection(), [a]);
assert!(u.app.ui.property_row.is_none());
}
#[test]
fn edit_copy_from_the_menu_copies_the_highlighted_row() {
let (mut u, _, _, _) = setup();
click_row(&mut u, "FF0000");
crate::menus::invoke(&mut u.app, "edit.copy");
assert!(u.app.session.clipboard.paint.as_ref().is_some_and(|(fills, _)| *fills), "the fill was copied");
assert!(u.app.session.clipboard.nodes.is_empty(), "not the layer");
}
#[test]
fn a_paste_onto_several_layers_adds_the_fill_to_each_in_one_undo_step() {
let (mut u, a, b, clip) = setup();
let c = rect(&mut u.app.session, 200.0, "#0000FF");
// A new rectangle is selected: A again.
select(&mut u, a);
click_row(&mut u, "FF0000");
copy(&mut u);
// B and C have different fills: the panel shows mixed content, no row to highlight.
u.app.run("select.set", json!({"ids": [b, c]}));
u.frame(vec![]);
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00", "FF0000"]);
assert_eq!(fills(&u, c), ["0000FF", "FF0000"]);
u.app.run("edit.undo", json!({}));
assert_eq!((fills(&u, b), fills(&u, c)), (vec!["00FF00".to_string()], vec!["0000FF".to_string()]));
}
#[test]
fn a_paste_replaces_the_highlighted_row_on_every_selected_layer() {
let (mut u, a, b, clip) = setup();
let c = rect(&mut u.app.session, 200.0, "#00FF00");
// A new rectangle is selected: A again.
select(&mut u, a);
click_row(&mut u, "FF0000");
copy(&mut u);
// B and C share their fill: its row shows for both.
u.app.run("select.set", json!({"ids": [b, c]}));
u.frame(vec![]);
click_row(&mut u, "00FF00");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.ids.clone()), Some(vec![b, c]));
paste(&mut u, &clip);
assert_eq!((fills(&u, b), fills(&u, c)), (vec!["FF0000".to_string()], vec!["FF0000".to_string()]));
u.app.run("edit.undo", json!({}));
assert_eq!((fills(&u, b), fills(&u, c)), (vec!["00FF00".to_string()], vec!["00FF00".to_string()]));
}
#[test]
fn a_copied_stroke_goes_to_the_strokes_with_a_fill_row_highlighted() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "0000FF");
copy(&mut u);
select(&mut u, b);
click_row(&mut u, "00FF00");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.kind), Some(PropertyKind::Fill));
paste(&mut u, &clip);
let doc = u.app.session.doc().unwrap();
assert_eq!(doc.node(b).unwrap().strokes, doc.node(a).unwrap().strokes, "the stroke went to the strokes");
assert_eq!(fills(&u, b), ["00FF00"], "the highlighted fill wasn't replaced");
}
#[test]
fn an_effect_copied_after_a_fill_is_what_pastes() {
let (mut u, a, b, clip) = setup();
click_row(&mut u, "FF0000");
copy(&mut u);
click_row(&mut u, "Layer blur");
copy(&mut u);
select(&mut u, b);
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00"], "the fill was copied first: not pasted");
let doc = u.app.session.doc().unwrap();
assert_eq!(doc.node(b).unwrap().effects.get(1), doc.node(a).unwrap().effects.first(), "the blur went on top of B's shadow");
}
#[test]
fn a_row_menu_copy_is_pasted_by_the_keyboard() {
let (mut u, _, b, clip) = setup();
let shapes = flat(&u.frame_output(vec![]).shapes);
let p = text_center(&shapes, "FF0000");
for pressed in [true, false] {
u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }]);
}
let menu = flat(&u.frame_output(vec![]).shapes);
click(&mut u, text_center(&menu, crate::i18n::tr("Copy fill")));
assert_eq!(clip.borrow().as_deref(), Some("#FF0000"));
select(&mut u, b);
paste(&mut u, &clip);
assert_eq!(fills(&u, b), ["00FF00", "FF0000"]);
}
#[test]
fn programmatic_copy_and_paste_of_a_row_leave_the_system_clipboard_alone() {
let (mut u, a, b, clip) = setup();
*clip.borrow_mut() = Some("the user's text".into());
let doc = u.app.session.ds().unwrap().uid;
u.app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 0, ids: vec![a], doc, rect: None });
u.app.control_synthetic = true;
crate::menus::invoke(&mut u.app, "edit.copy");
assert_eq!(clip.borrow().as_deref(), Some("the user's text"), "never written");
u.app.run("select.set", json!({"ids": [b]}));
crate::menus::paste(&mut u.app, None, None);
assert_eq!(fills(&u, b), ["00FF00", "FF0000"]);
}
#[test]
fn a_property_copy_whose_engine_copy_is_gone_pastes_no_stale_layers() {
let (mut u, a, b, clip) = setup();
// A layer copy first, then the fill row's.
copy(&mut u);
click_row(&mut u, "FF0000");
copy(&mut u);
// A stroke copied without its row (the Actions palette): the fill is no longer on the clipboard.
u.app.run("stroke.copy", json!({"id": a}));
select(&mut u, b);
let layers = u.app.session.doc().unwrap().node_count();
paste(&mut u, &clip);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "the layer copied before wasn't pasted");
assert_eq!(fills(&u, b), ["00FF00"]);
}
#[test]
fn a_highlighted_row_whose_paint_an_undo_took_away_goes() {
let (mut u, a, _, _) = setup();
u.app.run("fill.add", json!({"ids": [a], "paint": {"type": "solid", "color": "#123456"}}));
u.frame(vec![]);
click_row(&mut u, "123456");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| h.index), Some(1));
u.app.run("edit.undo", json!({}));
u.frame(vec![]);
u.frame(vec![]);
assert!(u.app.ui.property_row.is_none(), "its row isn't drawn any more");
assert_eq!(u.app.session.selection(), [a]);
}
#[test]
fn a_click_beside_the_rows_controls_highlights_it_too() {
let (mut u, _, _, _) = setup();
// The row's grip, left of the swatch, highlights it (`click_row`); so does the gap between its
// colour field and its visibility button (the grid's 8 pt gutter, 60 pt from the row's right).
click_row(&mut u, "FF0000");
let row = u.app.ui.property_row.as_ref().and_then(|h| h.rect).expect("the highlighted row was drawn");
key(&mut u, egui::Key::Escape);
assert!(u.app.ui.property_row.is_none());
click(&mut u, pos2(row.max.x - 64.0, row.center().y));
assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Fill, 0)));
}
#[test]
fn a_click_on_a_rows_grip_highlights_that_row_for_the_selection() {
let (mut u, a, b, _) = setup();
for (label, kind) in [("FF0000", PropertyKind::Fill), ("0000FF", PropertyKind::Stroke), ("Layer blur", PropertyKind::Effect)] {
click_row(&mut u, label);
assert_eq!(
u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index, h.ids.clone())),
Some((kind, 0, vec![a])),
"{label}: one row highlighted, for A"
);
}
// The row is drawn in the selected-row colour.
let t = crate::theme::Tokens::get(&u.ctx);
assert_eq!(crate::property_rows::fill(&u.app, PropertyKind::Effect, 0, t.selected_row), Some(t.selected_row));
assert_eq!(crate::property_rows::fill(&u.app, PropertyKind::Fill, 0, t.selected_row), None);
// It belonged to A: another selection lets it go, and A selected again doesn't bring it back.
select(&mut u, b);
assert!(u.app.ui.property_row.is_none());
select(&mut u, a);
assert!(u.app.ui.property_row.is_none());
}
#[test]
fn a_programmatic_paste_after_a_property_copy_whose_engine_copy_is_gone_pastes_no_stale_layers() {
let (mut u, a, b, _) = setup();
u.app.control_synthetic = true;
// A layer copy first, then the fill row's, both programmatic.
crate::menus::invoke(&mut u.app, "edit.copy");
assert_eq!(u.app.session.clipboard.nodes.len(), 1, "A was copied");
crate::property_rows::copy(&mut u.app, PropertyKind::Fill, 0, &[a]);
// A stroke copied without its row: the fill is no longer on the clipboard.
u.app.run("stroke.copy", json!({"id": a}));
u.app.run("select.set", json!({"ids": [b]}));
let layers = u.app.session.doc().unwrap().node_count();
crate::menus::paste(&mut u.app, None, None);
assert_eq!(u.app.session.doc().unwrap().node_count(), layers, "the layer copied before wasn't pasted");
assert_eq!(fills(&u, b), ["00FF00"]);
assert!(u.app.session.doc().unwrap().node(b).unwrap().strokes.is_empty());
}
#[test]
fn an_effect_paste_replaces_the_highlighted_effect_above_the_bottom_one() {
let (mut u, a, b, clip) = setup();
u.app.run("effect.add", json!({"ids": [b], "type": "innerShadow"}));
click_row(&mut u, "Layer blur");
copy(&mut u);
select(&mut u, b);
// B's effects, bottom to top: a drop shadow, an inner shadow (drawn first, at the top).
click_row(&mut u, "Inner shadow");
assert_eq!(u.app.ui.property_row.as_ref().map(|h| (h.kind, h.index)), Some((PropertyKind::Effect, 1)));
paste(&mut u, &clip);
let doc = u.app.session.doc().unwrap();
let (blur, effects) = (&doc.node(a).unwrap().effects[0], &doc.node(b).unwrap().effects);
assert_eq!(effects.len(), 2, "replaced, not added");
assert_eq!(effects[1], *blur, "the blur took the inner shadow's place");
assert_eq!(effects[0].label(), "Drop shadow", "the drop shadow under it stays");
}
#[test]
fn a_property_copied_in_one_window_pastes_in_another_and_the_highlight_stays_in_its_window() {
let mut s = Session::empty();
s.execute("file.new", &json!({"name": "A"})).unwrap();
let a = rect(&mut s, 0.0, "#FF0000");
s.execute("file.new", &json!({"name": "B"})).unwrap();
let b = rect(&mut s, 0.0, "#00FF00");
let clip = Rc::new(RefCell::new(None));
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
let mut app = crate::FigApp::new(s, Box::new(Clip(clip.clone())));
ctx.run_ui(egui::RawInput::default(), |ui| app.ui(ui)).textures_delta.clear();
// File A goes to a window of its own; the main window shows B.
let w = crate::control::handle(&mut app, &ctx, "window.new", &json!({"index": 0})).unwrap()["window"].as_u64().unwrap();
app.run("select.set", json!({"ids": [b]}));
let doc = app.session.ds().unwrap().uid;
app.ui.property_row = Some(Highlight { kind: PropertyKind::Fill, index: 0, ids: vec![b], doc, rect: None });
crate::menus::invoke(&mut app, "edit.copy");
assert_eq!(clip.borrow().as_deref(), Some("#00FF00"));
let text = clip.borrow().clone();
let pasted = crate::windows::as_window(&mut app, w, |app| {
assert!(app.ui.property_row.is_none(), "the highlight belongs to the main window");
app.run("select.set", json!({"ids": [a]}));
crate::menus::paste(app, text.as_deref(), None);
app.session.doc().unwrap().node(a).unwrap().fills.iter().map(|p| p.solid_color().unwrap().to_hex()).collect::<Vec<_>>()
});
let fills = pasted.expect("the window exists");
assert_eq!(fills, ["FF0000", "00FF00"], "B's fill went on top of A's");
assert!(app.ui.property_row.is_some(), "the main window keeps its highlight");
}
+5
View File
@@ -433,6 +433,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) {
return true;
}
// A fill, stroke or effect row highlighted in the Design panel: Escape lets it go, before the
// binding deselects the layers.
if key == Key::Escape && m.is_none() && !egui::Popup::is_any_open(ctx) && crate::property_rows::escape(app) {
return true;
}
if key_pick(app, key, m) || key_box(app, key, m) {
return true;
}
@@ -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");
}
+9 -3
View File
@@ -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);
}
+2
View File
@@ -236,6 +236,8 @@ fn exchange(app: &mut FigApp, p: &mut Pane) {
std::mem::swap(&mut app.ui, &mut p.ui);
std::mem::swap(&mut app.ui.last_copy, &mut p.ui.last_copy);
std::mem::swap(&mut app.ui.last_copy_generation, &mut p.ui.last_copy_generation);
// A paint or effect copied from its row is on the one clipboard every window pastes from.
std::mem::swap(&mut app.ui.property_copy, &mut p.ui.property_copy);
std::mem::swap(&mut app.ui.quit, &mut p.ui.quit);
std::mem::swap(&mut app.ui.dark, &mut p.ui.dark);
std::mem::swap(&mut app.canvas, &mut p.canvas);
+3 -2
View File
@@ -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. |
+1
View File
@@ -81,6 +81,7 @@ 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 |
+3 -1
View File
File diff suppressed because one or more lines are too long