mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 05:42:38 +00:00
Merge main into #2199
This commit is contained in:
commit
5727995274
24 files changed
+1513
-30
No files matched your search
@@ -258,3 +258,187 @@ fn template_and_table_commands_survive_junk() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Resizing a table (#2172): a height change goes into its rows, never into blank space.
|
||||
|
||||
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
/// A selected 3 x 3 table at 0,0 (480 x 122: a line under all but the last row adds 1 to each), with `texts` in the first row.
|
||||
fn table_3x3(texts: [&str; 3]) -> (Session, NodeId) {
|
||||
let mut s = board();
|
||||
let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [texts, ["", "", ""], ["", "", ""]]})).unwrap();
|
||||
(s, id(&r, "id"))
|
||||
}
|
||||
|
||||
fn rows_of(s: &Session, t: NodeId) -> Vec<NodeId> {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.node(t).unwrap().children.iter().copied().filter(|c| !node(s, *c).child_layout.absolute).collect()
|
||||
}
|
||||
|
||||
fn row_heights(s: &Session, t: NodeId) -> Vec<f64> {
|
||||
rows_of(s, t).into_iter().map(|r| node(s, r).height).collect()
|
||||
}
|
||||
|
||||
fn column_widths(s: &Session, t: NodeId) -> Vec<f64> {
|
||||
let r = rows_of(s, t)[0];
|
||||
node(s, r).children.iter().map(|c| node(s, *c).width).collect()
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 0.6
|
||||
}
|
||||
|
||||
fn all_near(got: &[f64], want: &[f64]) -> bool {
|
||||
got.len() == want.len() && got.iter().zip(want).all(|(a, b)| near(*a, *b))
|
||||
}
|
||||
|
||||
fn is_hug(s: &Session, t: NodeId) -> bool {
|
||||
node(s, t).child_layout.sizing_v == figcraft_doc::Sizing::Hug
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bottom_drag_grows_only_the_last_row() {
|
||||
// The programmatic route.
|
||||
let (mut s, t) = table_3x3(["Alpha", "", ""]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t));
|
||||
let n = node(&s, t);
|
||||
assert!(near(n.width, 480.0) && near(n.height, 272.0), "{}x{}", n.width, n.height);
|
||||
assert!(is_hug(&s, t), "still hugging its rows");
|
||||
assert!(all_near(&column_widths(&s, t), &[160.0; 3]));
|
||||
assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t));
|
||||
// The pointer route: the bottom edge dragged down by 150.
|
||||
let (mut s, t) = table_3x3(["Alpha", "", ""]);
|
||||
drag(&mut s, (240.0, 122.0), (240.0, 272.0));
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(near(node(&s, t).height, 272.0) && near(node(&s, t).width, 480.0));
|
||||
assert!(is_hug(&s, t));
|
||||
assert!(all_near(&column_widths(&s, t), &[160.0; 3]));
|
||||
assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t));
|
||||
// The text keeps its size.
|
||||
let row0 = rows_of(&s, t)[0];
|
||||
let cell = node(&s, node(&s, row0).children[0]);
|
||||
let NodeKind::Text(td) = &node(&s, cell.children[0]).kind else { panic!("text") };
|
||||
assert_eq!(td.style.font_size, 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corner_drag_shares_the_change_over_every_row_and_column() {
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
drag(&mut s, (480.0, 122.0), (570.0, 182.0));
|
||||
let n = node(&s, t);
|
||||
assert!(near(n.width, 570.0) && near(n.height, 182.0), "{}x{}", n.width, n.height);
|
||||
assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t));
|
||||
let xs: Vec<f64> = lines(&s, t).iter().map(|l| l.0).collect();
|
||||
assert!(all_near(&xs, &[189.5, 379.5]), "{xs:?}");
|
||||
assert!(is_hug(&s, t));
|
||||
// The same through `transform.resize`.
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
s.execute("transform.resize", &json!({"ids": [t], "width": 570, "height": 182})).unwrap();
|
||||
assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_side_drag_changes_no_row() {
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"width": 680}})).unwrap();
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0));
|
||||
assert!(is_hug(&s, t));
|
||||
// A pointer drag of the right edge.
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
drag(&mut s, (480.0, 60.0), (680.0, 60.0));
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
|
||||
assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0));
|
||||
assert!(is_hug(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_never_shrinks_below_its_cells() {
|
||||
let (mut s, t) = table_3x3(["Alpha", "Bravo longer text", "Charlie"]);
|
||||
drag(&mut s, (480.0, 122.0), (50.0, 32.0));
|
||||
let n = node(&s, t);
|
||||
assert!(near(n.width, 120.0), "width {}", n.width);
|
||||
assert!(all_near(&column_widths(&s, t), &[40.0; 3]), "{:?}", column_widths(&s, t));
|
||||
let rows = row_heights(&s, t);
|
||||
assert!(rows[0] > 40.0, "row 1 holds its wrapped text: {rows:?}");
|
||||
assert!(near(rows[1], 41.0) && near(rows[2], 40.0), "{rows:?}");
|
||||
assert!(near(n.height, rows.iter().sum::<f64>()), "{} vs {rows:?}", n.height);
|
||||
assert!(is_hug(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_table_still_grows_with_its_text_and_undoes_in_one_step() {
|
||||
// Text measures only once system fonts are scanned (without craft-fonts), so scan them here
|
||||
// without depending on another test having scanned them first.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
drag(&mut s, (240.0, 122.0), (240.0, 272.0));
|
||||
assert!(near(node(&s, t).height, 272.0));
|
||||
// Typing in a last-row cell: the table grows once the text needs more than 190.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 200.0, 1.0)).unwrap();
|
||||
assert!(s.ds().unwrap().text_edit.is_some(), "typing in the last row's cell");
|
||||
let long = (0..12).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
|
||||
crate::textedit::insert(&mut s, &long).unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
assert!(node(&s, t).height > 272.0, "grew with its text: {}", node(&s, t).height);
|
||||
assert!(is_hug(&s, t));
|
||||
// Undo: the typing, then the resize, to the table as made.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(node(&s, t).height, 272.0), "{}", node(&s, t).height);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let n = node(&s, t);
|
||||
assert!(near(n.width, 480.0) && near(n.height, 122.0), "{}x{}", n.width, n.height);
|
||||
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_resizes_sync_in_live_sessions() {
|
||||
let s = board();
|
||||
let mut room = Room::new(s.doc().unwrap().clone(), 2, 12);
|
||||
let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id");
|
||||
room.settle();
|
||||
room.check();
|
||||
room.s(0).execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
|
||||
room.settle();
|
||||
room.check();
|
||||
let doc = room.server.document();
|
||||
let heights: Vec<f64> =
|
||||
doc.node(t).unwrap().children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect();
|
||||
assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}");
|
||||
assert!(near(doc.node(t).unwrap().height, 272.0));
|
||||
assert_eq!(doc.node(t).unwrap().child_layout.sizing_v, figcraft_doc::Sizing::Hug);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_a_table_survives_junk_and_saves_the_rows() {
|
||||
for h in [json!(1e308), json!(-5), json!("NaN"), json!(null), json!(0)] {
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute("node.set", &json!({"id": t, "props": {"height": h}})) {
|
||||
panic!("{cmd} panicked on {h}: {msg}");
|
||||
}
|
||||
for p in [json!({"ids": [t], "width": h, "height": h}), json!({"ids": [t], "height": h}), json!({"ids": [t], "width": h})] {
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute("transform.resize", &p) {
|
||||
panic!("{cmd} panicked on {p}: {msg}");
|
||||
}
|
||||
}
|
||||
let n = node(&s, t);
|
||||
assert!(n.width.is_finite() && n.height.is_finite(), "{h}: {}x{}", n.width, n.height);
|
||||
}
|
||||
let (mut s, t) = table_3x3(["", "", ""]);
|
||||
s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
|
||||
let heights: Vec<f64> =
|
||||
back.node(t).unwrap().children.iter().map(|c| back.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect();
|
||||
assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}");
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTree
|
||||
if !is_board(doc) {
|
||||
return;
|
||||
}
|
||||
crate::board_table::settle_resize(doc, before, touched);
|
||||
for &id in touched {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if let Some(BoardItem::Shape { shape }) = n.board
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws
|
||||
//! a line under itself and the column lines are thin rectangles over the table that stretch with
|
||||
//! it (they are rebuilt after every edit, like the first row's header tint).
|
||||
//!
|
||||
//! Resizing (measured in FigJam's web app, 2026-11-09, #2172): a table never scales its text or
|
||||
//! its rows in proportion. A height change goes into the rows ([`settle_resize`]): the bottom or
|
||||
//! top edge grows the last row, a corner drag shares the change equally over every row (every
|
||||
//! cell fills its row, so the width is already shared equally), and the table keeps hugging its
|
||||
//! rows. A cell is at least [`MIN_CELL`] (40; FigJam's floor is about 52) wide and tall, and a row
|
||||
//! is never shorter than its wrapped text. There are no per-column widths yet: a side drag of
|
||||
//! the right edge splits the change equally where FigJam grows only the last column.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{
|
||||
@@ -22,6 +32,10 @@ pub const MAX_COLUMNS: usize = 20;
|
||||
/// A new column's width, and a cell's height before its text is measured.
|
||||
const CELL_W: f64 = 160.0;
|
||||
const CELL_H: f64 = 40.0;
|
||||
/// The least a cell is wide or tall (FigJam's is about 52).
|
||||
pub(crate) const MIN_CELL: f64 = CELL_H;
|
||||
/// The most a resize may ask one row to be tall.
|
||||
const MAX_ROW_H: f64 = 100_000.0;
|
||||
/// Room around a cell's text: [top, right, bottom, left].
|
||||
const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0];
|
||||
const TEXT_SIZE: f64 = 14.0;
|
||||
@@ -125,6 +139,7 @@ fn make_cell(doc: &mut Document, row: NodeId, index: Option<usize>, width: f64,
|
||||
n.child_layout.sizing_h = Sizing::Fill;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
n.child_layout.min_h = Some(CELL_H);
|
||||
n.child_layout.min_w = Some(MIN_CELL);
|
||||
})?;
|
||||
add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?;
|
||||
Ok(cell)
|
||||
@@ -259,6 +274,57 @@ fn table_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A table resized by its height or a corner (the resize leaves it at a fixed height): the change
|
||||
/// goes into its rows, as in FigJam, and the table hugs them again. A bottom or top edge (or a
|
||||
/// height set by command) grows or shrinks the last row; a drag that changed both axes shares the
|
||||
/// change equally over every row. A row never gets shorter than [`MIN_CELL`] or than its text (the
|
||||
/// table hugs it), and the table is never narrower than its columns' minimum. A table already
|
||||
/// hugging (a peer's synced edit, a second pass) is left alone, so the change is made once.
|
||||
pub(crate) fn settle_resize(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
|
||||
let mut tables = BTreeSet::new();
|
||||
for &id in touched {
|
||||
if let Some(t) = table_of(doc, id) {
|
||||
tables.insert(t);
|
||||
}
|
||||
}
|
||||
for table in tables {
|
||||
let (Some(now), Some(was)) = (doc.node(table), before.node(table)) else { continue };
|
||||
if now.child_layout.sizing_v != Sizing::Fixed {
|
||||
continue;
|
||||
}
|
||||
let (w, h, bw, bh) = (now.width, now.height, was.width, was.height);
|
||||
let (rows, _) = parts(doc, table);
|
||||
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
|
||||
let dh = h - bh;
|
||||
if dh.is_finite() && dh.abs() > 1e-6 && !rows.is_empty() {
|
||||
let both = w.is_finite() && bw.is_finite() && (w - bw).abs() > 1e-6;
|
||||
let targets: Vec<NodeId> = if both { rows.clone() } else { rows.last().copied().into_iter().collect() };
|
||||
let share = dh / targets.len() as f64;
|
||||
for row in targets {
|
||||
let base = row_content(before, row).or_else(|| row_content(doc, row)).unwrap_or(CELL_H);
|
||||
let min_h = (base + share).clamp(MIN_CELL, MAX_ROW_H);
|
||||
for cell in cells(doc, row) {
|
||||
if let Some(c) = doc.node_mut(cell) {
|
||||
c.child_layout.min_h = Some(min_h);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(t) = doc.node_mut(table) {
|
||||
let least = MIN_CELL * columns as f64;
|
||||
if t.width.is_nan() || t.width < least {
|
||||
t.width = least;
|
||||
}
|
||||
t.child_layout.sizing_v = Sizing::Hug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How tall a row's cells are: the row's height less the line under it.
|
||||
fn row_content(doc: &Document, row: NodeId) -> Option<f64> {
|
||||
cells(doc, row).into_iter().filter_map(|c| doc.node(c).map(|n| n.height)).reduce(f64::max)
|
||||
}
|
||||
|
||||
/// The table `id` is or is in.
|
||||
fn table_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table)))
|
||||
|
||||
@@ -534,6 +534,9 @@ name = "English"
|
||||
"Edit padding on all sides" = "Edit padding on all sides"
|
||||
"Edit style" = "Edit style"
|
||||
"Edit variable" = "Edit variable"
|
||||
"Edit variables" = "Edit variables"
|
||||
"Scope" = "Scope"
|
||||
"No scope is shared by all the selected variables" = "No scope is shared by all the selected variables"
|
||||
"Edit with the builder" = "Edit with the builder"
|
||||
"Edited {when}" = "Edited {when}"
|
||||
"Effect settings" = "Effect settings"
|
||||
|
||||
@@ -883,9 +883,17 @@ pub fn tab(resp: &Response, label: &str, selected: bool) {
|
||||
}
|
||||
|
||||
pub fn checkbox(resp: &Response, label: &str, on: bool) {
|
||||
checkbox_tri(resp, label, Some(on));
|
||||
}
|
||||
|
||||
/// A checkbox that can be mixed (`None`, announced as "mixed" rather than unchecked).
|
||||
pub fn checkbox_tri(resp: &Response, label: &str, state: Option<bool>) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::CheckBox);
|
||||
n.set_toggled(toggled(on));
|
||||
n.set_toggled(match state {
|
||||
Some(on) => toggled(on),
|
||||
None => Toggled::Mixed,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -431,3 +431,80 @@ fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() {
|
||||
}
|
||||
assert!(!seen.contains(&left) && !seen.contains(&right), "Tab never stops on a panel edge: {seen:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_checkbox_is_announced_as_mixed_and_a_checked_one_as_true() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.enable_accesskit();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::widgets::checkbox_tri(ui, None, "Mixed one");
|
||||
crate::widgets::checkbox_tri(ui, Some(true), "Checked one");
|
||||
crate::widgets::checkbox_tri(ui, Some(false), "Plain one");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let nodes: Vec<Node> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect();
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Mixed one").toggled(), Some(Toggled::Mixed));
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Checked one").toggled(), Some(Toggled::True));
|
||||
assert_eq!(find(&nodes, Role::CheckBox, "Plain one").toggled(), Some(Toggled::False));
|
||||
}
|
||||
|
||||
/// Runs one headless frame of a lone tri-state checkbox; returns whether it was clicked.
|
||||
fn tri_frame(ctx: &egui::Context, state: Option<bool>, events: Vec<Event>) -> bool {
|
||||
let mut clicked = false;
|
||||
let input = egui::RawInput { events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
clicked = crate::widgets::checkbox_tri(ui, state, "Keyed one");
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
clicked
|
||||
}
|
||||
|
||||
fn key(key: Key, pressed: bool) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_checkbox_is_keyboard_operable_tab_to_it_then_space_or_enter_toggles_it() {
|
||||
for activate in [Key::Space, Key::Enter] {
|
||||
let ctx = egui::Context::default();
|
||||
tri_frame(&ctx, Some(false), vec![]);
|
||||
tri_frame(&ctx, Some(false), vec![key(Key::Tab, true), key(Key::Tab, false)]);
|
||||
assert!(ctx.memory(|m| m.focused()).is_some(), "Tab focuses the checkbox");
|
||||
tri_frame(&ctx, Some(false), vec![]);
|
||||
let clicked = tri_frame(&ctx, Some(false), vec![key(activate, true), key(activate, false)]);
|
||||
assert!(clicked, "{activate:?} on the focused checkbox toggles it");
|
||||
}
|
||||
}
|
||||
|
||||
/// The marks (paths, meshes, segments) painted inside the 14-point box of a lone checkbox.
|
||||
fn box_marks(state: Option<bool>) -> Vec<egui::Rect> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<egui::Rect>) {
|
||||
match s {
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } => out.push(s.visual_bounding_rect()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let mut o = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
crate::widgets::checkbox_tri(ui, state, "Painted one");
|
||||
});
|
||||
o.textures_delta.clear();
|
||||
out.clear();
|
||||
o.shapes.iter().for_each(|c| walk(&c.shape, &mut out));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mixed_checkbox_paints_a_dash_and_a_checked_one_a_tick() {
|
||||
// Icon::Minus is one flat bar; Icon::Check is a taller path.
|
||||
let mixed = box_marks(None);
|
||||
assert!(mixed.iter().any(|r| r.width() >= 5.0 && r.height() <= 2.5), "a flat dash is painted for the mixed state: {mixed:?}");
|
||||
let checked = box_marks(Some(true));
|
||||
assert!(checked.iter().any(|r| r.width() >= 5.0 && r.height() > 3.5), "a tick is painted for the checked state: {checked:?}");
|
||||
assert!(!checked.iter().any(|r| r.width() >= 5.0 && r.height() <= 2.5), "no dash on a checked box: {checked:?}");
|
||||
assert!(box_marks(Some(false)).is_empty(), "an empty box paints no mark");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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, "") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link aLine truncated
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -36,6 +36,7 @@ interaction feel not yet compared.
|
||||
| Colour picker image well | partial | the Image tab shows a checkered placeholder and Upload from computer until there is a file (no dialog at once); an image paint shows its asset preview; a file dropped on the open picker replaces the image, keeping the fill mode, crop, position and adjustments (`image.replace`). Layers' fills and strokes only (text ranges, styles and selection colours keep Upload). The Fill and Stroke swatches take no drop: a dragged file's position isn't known, so a drop with none goes to the open picker |
|
||||
| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) |
|
||||
| Fill section on a text with coloured characters | done | a text's Fill section shows what its characters show: characters coloured differently while editing are mixed content ("Click + to replace mixed content", as a mixed selection; the developer documentation's `fills`: "Text nodes can have multiple sets of fills if some characters are colored differently than others"), and characters that all share fills the layer doesn't have show those. Any layer-level fill edit (a row, its picker, +, reordering, `fill.*`, `node.set`) reaches every character, starting from the list they showed; the characters' own fills go in the same undo step; the canvas gradient and crop handles show and edit the paint the row shows, the characters' own, also when their image is already in Crop mode (`text_mixed_fill_tests`, `canvas::handles::character_fill_tests`, engine `text_fill_tests`; #1856). A range selected while editing keeps its own Fill row |
|
||||
| Fill, stroke and effect rows: click to highlight, Cmd/Ctrl+C, Cmd/Ctrl+V | done | a click on a row's reorder grip (left of its fields) or its background between its controls highlights it in the selected-row colour, one row at a time; Cmd/Ctrl+C (or Edit ▸ Copy) then copies that paint or effect instead of the layers (`fill.copy`, `stroke.copy`, `effect.copy`, as the row's menu does; a stroke takes its weight and settings along) and Cmd/Ctrl+V pastes it onto every selected layer (`fill.paste`, `stroke.paste`, `effect.paste`), one undo step: on top of each layer's list, or in place of the highlighted row when it is of the same kind. A copy keeps its kind: a stroke goes to the strokes and an effect to the effects whatever row is highlighted, and the last row copied is the one pasted. A solid colour also goes to the system clipboard as its hex code; a programmatic copy never writes it. A later layer copy, or text or an image copied in another app, is pasted instead; every window pastes the same copy. Escape, a press on the canvas or elsewhere in the Design panel, another selection, file or tab let the highlight go (#1494). The help center says only that a property is highlighted with a click; adding on top follows the forum's accepted answer for adding one fill without replacing the others, not an observation. Delete and Cmd/Ctrl+X still act on the layers; the keyboard alone can't highlight a row |
|
||||
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
|
||||
|
||||
## Layers and Assets panels
|
||||
@@ -198,6 +199,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| 2026-11-07 | patch | Main menu: a submenu opens on the first frame its row is hovered instead of after 0.15 s (Figma's main menu measured with synthetic pointer events and 1 ms polling: median 11 ms to open, 13 ms to switch rows, no hover delay); the safe-triangle aim is unchanged, though the same measurement saw no aim logic in Figma, left for the maintainer to decide |
|
||||
| 2026-11-10 | patch | M5, #1560: locked layers follow Figma's rules (Lock and unlock layers; measured in Figma's web app): a locked layer selected in the Layers panel ignores the arrow keys and the nudge commands while its properties still edit, and a child of a locked layer keeps its padlock inert (the Layers panel padlock, Lock/Unlock and `object.toggleLock` without a value no longer lock the child on its own) |
|
||||
| 2026-11-10 | patch | #2217: the main menu's submenus (Object, say) ran past the window's bottom, behind the taskbar, with the lowest commands out of reach, because submenus were not constrained to the window (to keep them beside their row). Every menu panel now scrolls when taller than the window less 8 pt margins, and a submenu is held within the window's height (moved up, never above the top) while keeping its place beside its row, so menu-aim is unchanged. Tests: `menu_overflow_tests.rs`. Numbers unchanged. |
|
||||
| 2026-11-09 | minor | M1, #1494: fill, stroke and effect rows in the Design panel couldn't be selected, so the keyboard couldn't copy one (only the row's menu could). A click on a row's grip or background now highlights it (`property_rows`; the row is a click target under its controls and its grip takes a click, neither focusable, so Tab still goes control to control), Cmd/Ctrl+C copies that paint or effect, and Cmd/Ctrl+V pastes it onto the selected layers, on top or in place of their highlighted row of that kind, while it is still the last copy (Copy and paste properties between layers). Escape or a press on the canvas lets the highlight go before the selection. Tests: `property_rows_tests` (22) |
|
||||
| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |
|
||||
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
|
||||
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
|
||||
|
||||
Reference in new issue
Block a user