Merge main into #2241

This commit is contained in:
bflatastic committed 2026-11-12 11:40:47 +07:00
commit 3cd2b85819
24 files changed
+1513 -30

No files matched your search

+184
View File
@@ -258,3 +258,187 @@ fn template_and_table_commands_survive_junk() {
}
}
}
// ---------------------------------------------------------------------------------------------
// Resizing a table (#2172): a height change goes into its rows, never into blank space.
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
}
/// A selected 3 x 3 table at 0,0 (480 x 122: a line under all but the last row adds 1 to each), with `texts` in the first row.
fn table_3x3(texts: [&str; 3]) -> (Session, NodeId) {
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [texts, ["", "", ""], ["", "", ""]]})).unwrap();
(s, id(&r, "id"))
}
fn rows_of(s: &Session, t: NodeId) -> Vec<NodeId> {
let doc = s.doc().unwrap();
doc.node(t).unwrap().children.iter().copied().filter(|c| !node(s, *c).child_layout.absolute).collect()
}
fn row_heights(s: &Session, t: NodeId) -> Vec<f64> {
rows_of(s, t).into_iter().map(|r| node(s, r).height).collect()
}
fn column_widths(s: &Session, t: NodeId) -> Vec<f64> {
let r = rows_of(s, t)[0];
node(s, r).children.iter().map(|c| node(s, *c).width).collect()
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 0.6
}
fn all_near(got: &[f64], want: &[f64]) -> bool {
got.len() == want.len() && got.iter().zip(want).all(|(a, b)| near(*a, *b))
}
fn is_hug(s: &Session, t: NodeId) -> bool {
node(s, t).child_layout.sizing_v == figcraft_doc::Sizing::Hug
}
#[test]
fn a_bottom_drag_grows_only_the_last_row() {
// The programmatic route.
let (mut s, t) = table_3x3(["Alpha", "", ""]);
s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t));
let n = node(&s, t);
assert!(near(n.width, 480.0) && near(n.height, 272.0), "{}x{}", n.width, n.height);
assert!(is_hug(&s, t), "still hugging its rows");
assert!(all_near(&column_widths(&s, t), &[160.0; 3]));
assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t));
// The pointer route: the bottom edge dragged down by 150.
let (mut s, t) = table_3x3(["Alpha", "", ""]);
drag(&mut s, (240.0, 122.0), (240.0, 272.0));
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 190.0]), "{:?}", row_heights(&s, t));
assert!(near(node(&s, t).height, 272.0) && near(node(&s, t).width, 480.0));
assert!(is_hug(&s, t));
assert!(all_near(&column_widths(&s, t), &[160.0; 3]));
assert!(lines(&s, t).iter().all(|(_, h)| near(*h, 272.0)), "{:?}", lines(&s, t));
// The text keeps its size.
let row0 = rows_of(&s, t)[0];
let cell = node(&s, node(&s, row0).children[0]);
let NodeKind::Text(td) = &node(&s, cell.children[0]).kind else { panic!("text") };
assert_eq!(td.style.font_size, 14.0);
}
#[test]
fn a_corner_drag_shares_the_change_over_every_row_and_column() {
let (mut s, t) = table_3x3(["", "", ""]);
drag(&mut s, (480.0, 122.0), (570.0, 182.0));
let n = node(&s, t);
assert!(near(n.width, 570.0) && near(n.height, 182.0), "{}x{}", n.width, n.height);
assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t));
assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t));
let xs: Vec<f64> = lines(&s, t).iter().map(|l| l.0).collect();
assert!(all_near(&xs, &[189.5, 379.5]), "{xs:?}");
assert!(is_hug(&s, t));
// The same through `transform.resize`.
let (mut s, t) = table_3x3(["", "", ""]);
s.execute("transform.resize", &json!({"ids": [t], "width": 570, "height": 182})).unwrap();
assert!(all_near(&row_heights(&s, t), &[61.0, 61.0, 60.0]), "{:?}", row_heights(&s, t));
assert!(all_near(&column_widths(&s, t), &[190.0; 3]), "{:?}", column_widths(&s, t));
}
#[test]
fn a_side_drag_changes_no_row() {
let (mut s, t) = table_3x3(["", "", ""]);
s.execute("node.set", &json!({"id": t, "props": {"width": 680}})).unwrap();
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0));
assert!(is_hug(&s, t));
// A pointer drag of the right edge.
let (mut s, t) = table_3x3(["", "", ""]);
drag(&mut s, (480.0, 60.0), (680.0, 60.0));
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
assert!(near(node(&s, t).height, 122.0) && near(node(&s, t).width, 680.0));
assert!(is_hug(&s, t));
}
#[test]
fn a_table_never_shrinks_below_its_cells() {
let (mut s, t) = table_3x3(["Alpha", "Bravo longer text", "Charlie"]);
drag(&mut s, (480.0, 122.0), (50.0, 32.0));
let n = node(&s, t);
assert!(near(n.width, 120.0), "width {}", n.width);
assert!(all_near(&column_widths(&s, t), &[40.0; 3]), "{:?}", column_widths(&s, t));
let rows = row_heights(&s, t);
assert!(rows[0] > 40.0, "row 1 holds its wrapped text: {rows:?}");
assert!(near(rows[1], 41.0) && near(rows[2], 40.0), "{rows:?}");
assert!(near(n.height, rows.iter().sum::<f64>()), "{} vs {rows:?}", n.height);
assert!(is_hug(&s, t));
}
#[test]
fn a_resized_table_still_grows_with_its_text_and_undoes_in_one_step() {
// Text measures only once system fonts are scanned (without craft-fonts), so scan them here
// without depending on another test having scanned them first.
figcraft_text::scan_system_fonts();
let (mut s, t) = table_3x3(["", "", ""]);
drag(&mut s, (240.0, 122.0), (240.0, 272.0));
assert!(near(node(&s, t).height, 272.0));
// Typing in a last-row cell: the table grows once the text needs more than 190.
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 200.0, 1.0)).unwrap();
assert!(s.ds().unwrap().text_edit.is_some(), "typing in the last row's cell");
let long = (0..12).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
crate::textedit::insert(&mut s, &long).unwrap();
crate::textedit::end(&mut s).unwrap();
assert!(node(&s, t).height > 272.0, "grew with its text: {}", node(&s, t).height);
assert!(is_hug(&s, t));
// Undo: the typing, then the resize, to the table as made.
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(node(&s, t).height, 272.0), "{}", node(&s, t).height);
s.execute("edit.undo", &json!({})).unwrap();
let n = node(&s, t);
assert!(near(n.width, 480.0) && near(n.height, 122.0), "{}x{}", n.width, n.height);
assert!(all_near(&row_heights(&s, t), &[41.0, 41.0, 40.0]), "{:?}", row_heights(&s, t));
}
#[test]
fn table_resizes_sync_in_live_sessions() {
let s = board();
let mut room = Room::new(s.doc().unwrap().clone(), 2, 12);
let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id");
room.settle();
room.check();
room.s(0).execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
room.settle();
room.check();
let doc = room.server.document();
let heights: Vec<f64> =
doc.node(t).unwrap().children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect();
assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}");
assert!(near(doc.node(t).unwrap().height, 272.0));
assert_eq!(doc.node(t).unwrap().child_layout.sizing_v, figcraft_doc::Sizing::Hug);
}
#[test]
fn resizing_a_table_survives_junk_and_saves_the_rows() {
for h in [json!(1e308), json!(-5), json!("NaN"), json!(null), json!(0)] {
let (mut s, t) = table_3x3(["", "", ""]);
if let Err(EngineError::Internal { cmd, msg }) = s.execute("node.set", &json!({"id": t, "props": {"height": h}})) {
panic!("{cmd} panicked on {h}: {msg}");
}
for p in [json!({"ids": [t], "width": h, "height": h}), json!({"ids": [t], "height": h}), json!({"ids": [t], "width": h})] {
if let Err(EngineError::Internal { cmd, msg }) = s.execute("transform.resize", &p) {
panic!("{cmd} panicked on {p}: {msg}");
}
}
let n = node(&s, t);
assert!(n.width.is_finite() && n.height.is_finite(), "{h}: {}x{}", n.width, n.height);
}
let (mut s, t) = table_3x3(["", "", ""]);
s.execute("node.set", &json!({"id": t, "props": {"height": 272}})).unwrap();
let doc = s.doc().unwrap();
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
let heights: Vec<f64> =
back.node(t).unwrap().children.iter().map(|c| back.node(*c).unwrap()).filter(|n| !n.child_layout.absolute).map(|n| n.height).collect();
assert!(all_near(&heights, &[41.0, 41.0, 190.0]), "{heights:?}");
}
@@ -14,6 +14,7 @@ pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTree
if !is_board(doc) {
return;
}
crate::board_table::settle_resize(doc, before, touched);
for &id in touched {
let Some(n) = doc.node(id) else { continue };
if let Some(BoardItem::Shape { shape }) = n.board
+66
View File
@@ -3,6 +3,16 @@
//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws
//! a line under itself and the column lines are thin rectangles over the table that stretch with
//! it (they are rebuilt after every edit, like the first row's header tint).
//!
//! Resizing (measured in FigJam's web app, 2026-11-09, #2172): a table never scales its text or
//! its rows in proportion. A height change goes into the rows ([`settle_resize`]): the bottom or
//! top edge grows the last row, a corner drag shares the change equally over every row (every
//! cell fills its row, so the width is already shared equally), and the table keeps hugging its
//! rows. A cell is at least [`MIN_CELL`] (40; FigJam's floor is about 52) wide and tall, and a row
//! is never shorter than its wrapped text. There are no per-column widths yet: a side drag of
//! the right edge splits the change equally where FigJam grows only the last column.
use std::collections::BTreeSet;
use figcraft_color::{Color, Paint};
use figcraft_doc::{
@@ -22,6 +32,10 @@ pub const MAX_COLUMNS: usize = 20;
/// A new column's width, and a cell's height before its text is measured.
const CELL_W: f64 = 160.0;
const CELL_H: f64 = 40.0;
/// The least a cell is wide or tall (FigJam's is about 52).
pub(crate) const MIN_CELL: f64 = CELL_H;
/// The most a resize may ask one row to be tall.
const MAX_ROW_H: f64 = 100_000.0;
/// Room around a cell's text: [top, right, bottom, left].
const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0];
const TEXT_SIZE: f64 = 14.0;
@@ -125,6 +139,7 @@ fn make_cell(doc: &mut Document, row: NodeId, index: Option<usize>, width: f64,
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.sizing_v = Sizing::Hug;
n.child_layout.min_h = Some(CELL_H);
n.child_layout.min_w = Some(MIN_CELL);
})?;
add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?;
Ok(cell)
@@ -259,6 +274,57 @@ fn table_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// A table resized by its height or a corner (the resize leaves it at a fixed height): the change
/// goes into its rows, as in FigJam, and the table hugs them again. A bottom or top edge (or a
/// height set by command) grows or shrinks the last row; a drag that changed both axes shares the
/// change equally over every row. A row never gets shorter than [`MIN_CELL`] or than its text (the
/// table hugs it), and the table is never narrower than its columns' minimum. A table already
/// hugging (a peer's synced edit, a second pass) is left alone, so the change is made once.
pub(crate) fn settle_resize(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
let mut tables = BTreeSet::new();
for &id in touched {
if let Some(t) = table_of(doc, id) {
tables.insert(t);
}
}
for table in tables {
let (Some(now), Some(was)) = (doc.node(table), before.node(table)) else { continue };
if now.child_layout.sizing_v != Sizing::Fixed {
continue;
}
let (w, h, bw, bh) = (now.width, now.height, was.width, was.height);
let (rows, _) = parts(doc, table);
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
let dh = h - bh;
if dh.is_finite() && dh.abs() > 1e-6 && !rows.is_empty() {
let both = w.is_finite() && bw.is_finite() && (w - bw).abs() > 1e-6;
let targets: Vec<NodeId> = if both { rows.clone() } else { rows.last().copied().into_iter().collect() };
let share = dh / targets.len() as f64;
for row in targets {
let base = row_content(before, row).or_else(|| row_content(doc, row)).unwrap_or(CELL_H);
let min_h = (base + share).clamp(MIN_CELL, MAX_ROW_H);
for cell in cells(doc, row) {
if let Some(c) = doc.node_mut(cell) {
c.child_layout.min_h = Some(min_h);
}
}
}
}
if let Some(t) = doc.node_mut(table) {
let least = MIN_CELL * columns as f64;
if t.width.is_nan() || t.width < least {
t.width = least;
}
t.child_layout.sizing_v = Sizing::Hug;
}
}
}
/// How tall a row's cells are: the row's height less the line under it.
fn row_content(doc: &Document, row: NodeId) -> Option<f64> {
cells(doc, row).into_iter().filter_map(|c| doc.node(c).map(|n| n.height)).reduce(f64::max)
}
/// The table `id` is or is in.
fn table_of(doc: &Document, id: NodeId) -> Option<NodeId> {
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table)))