diff --git a/ROADMAP.md b/ROADMAP.md index b153e1ca1..614856bba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -356,6 +356,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. | +| 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | diff --git a/crates/engine/src/board_connected_tests.rs b/crates/engine/src/board_connected_tests.rs new file mode 100644 index 000000000..0b91aad82 --- /dev/null +++ b/crates/engine/src/board_connected_tests.rs @@ -0,0 +1,106 @@ +//! `board.addConnected` (#1597): the next sticky or shape on a side of the selected one, of the +//! same kind, size and colour, joined to it by a connector, in one undo step. + +use figcraft_doc::{BoardItem, BoardShape, Connector, End, NodeId, Side}; +use serde_json::json; + +use crate::Session; +use crate::board::CONNECTED_GAP; +use crate::board_tests::{board, id, node}; + +fn connector(s: &Session, c: NodeId) -> Connector { + match &node(s, c).board { + Some(BoardItem::Connector(k)) => *k, + other => panic!("not a connector: {other:?}"), + } +} + +fn at(s: &Session, n: NodeId) -> (f64, f64) { + let t = node(s, n).transform.translation(); + (t.x, t.y) +} + +#[test] +fn a_shape_gets_the_next_one_joined_on_each_side() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120, "color": "blue"})).unwrap(), "id"); + let colour = |s: &Session, n: NodeId| node(s, node(s, n).children[0]).fills[0].solid_color(); + let g = CONNECTED_GAP; + for (side, want, ends) in [ + ("right", (200.0 + g, 0.0), (Side::Right, Side::Left)), + ("below", (0.0, 120.0 + g), (Side::Bottom, Side::Top)), + ("left", (-200.0 - g, 0.0), (Side::Left, Side::Right)), + ("above", (0.0, -120.0 - g), (Side::Top, Side::Bottom)), + ] { + let r = s.execute("board.addConnected", &json!({"id": a, "side": side})).unwrap(); + let (b, c) = (id(&r, "id"), id(&r, "connector")); + assert_eq!(at(&s, b), want, "{side}"); + let nb = node(&s, b); + assert_eq!((nb.width, nb.height), (200.0, 120.0), "{side}: the same size"); + assert!(matches!(nb.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })), "{side}: the same shape"); + assert_eq!(colour(&s, b), colour(&s, a), "{side}: the same colour"); + let k = connector(&s, c); + assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0 }, End::Node { node: b, side: ends.1 }), "{side}"); + assert_eq!(k.routing, s.tool.board.routing, "{side}: as the connector tool makes one"); + assert_eq!(s.selection(), vec![b], "{side}: the new one is selected"); + } +} + +#[test] +fn a_sticky_gets_a_sticky_of_its_colour_and_typing_goes_on_in_it() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink", "text": "First"})).unwrap(), "id"); + s.ui_requests.clear(); + // The selection is the default, and editing is asked for. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("board.addConnected", &json!({"side": "below", "edit": true})).unwrap(); + let b = id(&r, "id"); + assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { .. }))); + assert_eq!(node(&s, b).fills[0].solid_color(), node(&s, a).fills[0].solid_color()); + assert_eq!(at(&s, b).1, node(&s, a).height + CONNECTED_GAP); + assert!(s.ui_requests.iter().any(|q| q.kind == "board.editText" && q.data["id"] == json!(b))); +} + +#[test] +fn it_is_one_undo_step_and_refuses_what_it_cant_join() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0})).unwrap(), "id"); + let before = s.doc().unwrap().ids().count(); + let r = s.execute("board.addConnected", &json!({"id": a})).unwrap(); + assert_eq!(at(&s, id(&r, "id")).0, 160.0 + CONNECTED_GAP, "right by default"); + assert!(s.doc().unwrap().ids().count() > before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().ids().count(), before, "the shape and its connector go together"); + // Not a sticky or shape, not a side, nothing selected. + let c = id(&s.execute("board.stamp", &json!({"x": 400, "y": 0, "stamp": "star"})).unwrap(), "id"); + assert!(s.execute("board.addConnected", &json!({"id": c})).is_err()); + assert!(s.execute("board.addConnected", &json!({"id": a, "side": "diagonal"})).is_err()); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.execute("board.addConnected", &json!({})).is_err()); + // Not on a design file. + let mut d = Session::new(); + assert!(d.execute("board.addConnected", &json!({})).is_err()); +} + +#[test] +fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id"); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + // Shift+Cmd/Ctrl+Enter on a shape: the next shape on the right, joined by a connector (#1597). + let r = s.execute("board.nextSticky", &json!({})).unwrap(); + let (b, c) = (id(&r, "id"), id(&r, "connector")); + assert!(matches!(node(&s, b).board, Some(BoardItem::Shape { shape: BoardShape::Rectangle }))); + assert_eq!(at(&s, b), (200.0 + CONNECTED_GAP, 0.0)); + assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right }); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Another side, as the command takes one. + let r = s.execute("board.nextSticky", &json!({"id": a, "side": "below"})).unwrap(); + assert_eq!(at(&s, id(&r, "id")), (0.0, 120.0 + CONNECTED_GAP)); + // A sticky still gets a sticky beside it, without a connector. + let st = id(&s.execute("board.sticky", &json!({"x": 1000, "y": 0})).unwrap(), "id"); + let r = s.execute("board.nextSticky", &json!({"id": st})).unwrap(); + assert!(r.get("connector").is_none(), "{r}"); + assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. }))); +} diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs index b40bcc7d6..b1ebe81b9 100644 --- a/crates/engine/src/boards/board.rs +++ b/crates/engine/src/boards/board.rs @@ -36,8 +36,9 @@ pub use edit::label_of; pub(crate) use edit::{copied_items, copy_map, rejoin}; use edit::{label_cmd, set_color, set_style}; pub(crate) use items::add_text; +pub use items::{CONNECTED_GAP, connected_place}; +use items::{add_connected, ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd}; pub use items::{edit_text, owner_of_text, sticky_font_size, text_of}; -use items::{ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd}; /// Why a board command refuses on a design file. pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; @@ -103,10 +104,17 @@ pub fn specs() -> Vec { spec!( "board.nextSticky", "Next sticky note", - "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", + "{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector?} a sticky of the same colour beside a sticky; after a shape, the next shape joined to it by a connector, as board.addConnected", true, next_sticky ), + spec!( + "board.addConnected", + "Add connected shape", + "{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector", + true, + add_connected + ), spec!( "board.shape", "Shape with text", diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs index b76fc8986..28b2cfec9 100644 --- a/crates/engine/src/boards/board/items.rs +++ b/crates/engine/src/boards/board/items.rs @@ -1,9 +1,10 @@ +use super::connectors::{CAPS, make}; use super::{ - AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError, - HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP, - STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork, - author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string, - textedit, + AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Connector, Constraints, CrossAlign, Document, Effect, EffectKind, End, + EngineError, HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, + STICKY_GAP, STICKY_PAD, Session, Shadow, Side, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, + Value, VectorNetwork, author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, + stamp_of, string, textedit, }; /// A new layer at the top of `parent`, at `at` in it, made by `f`. @@ -144,10 +145,14 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result { let from = id(p, "id") .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?; let n = doc.get(from)?; + if matches!(n.board, Some(BoardItem::Shape { .. })) { + // A shape gets the next one joined to it, as the "+" beside it makes (#1597). + return add_connected(s, p); + } if !matches!(n.board, Some(BoardItem::Sticky { .. })) { - return Err(bad(cmd, "that isn't a sticky")); + return Err(bad(cmd, "that isn't a sticky or a shape")); } let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; @@ -172,6 +177,70 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id, "text": note})) } +/// The room between a board object and the next one `board.addConnected` makes, joined by a +/// connector: enough for the arrow to read. +pub const CONNECTED_GAP: f64 = 80.0; + +/// Where `board.addConnected` puts the next object on `side` of one at `at` sized `size`, `next` +/// big (its top-left), and the sides the connector joins (from, to). `None`: not a side. +pub fn connected_place(at: (f64, f64), size: (f64, f64), next: (f64, f64), side: &str) -> Option<((f64, f64), Side, Side)> { + let ((x, y), (w, h), (nw, nh)) = (at, size, next); + let (cx, cy) = (x + (w - nw) / 2.0, y + (h - nh) / 2.0); + Some(match side { + "right" => ((x + w + CONNECTED_GAP, cy), Side::Right, Side::Left), + "left" => ((x - CONNECTED_GAP - nw, cy), Side::Left, Side::Right), + "below" => ((cx, y + h + CONNECTED_GAP), Side::Bottom, Side::Top), + "above" => ((cx, y - CONNECTED_GAP - nh), Side::Top, Side::Bottom), + _ => return None, + }) +} + +/// `board.addConnected`: the next sticky or shape on a side of one, of the same kind, size and +/// colour, joined to it by a connector the way the connector tool makes one; one undo step. +pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result { + let cmd = "board.addConnected"; + require(s, cmd)?; + let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); + let doc = s.doc()?; + let from = id(p, "id") + .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) + .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?; + let n = doc.get(from)?; + let shape = match &n.board { + Some(BoardItem::Sticky { .. }) => None, + Some(BoardItem::Shape { shape }) => Some(*shape), + _ => return Err(bad(cmd, "that isn't a sticky or a shape")), + }; + let side = string(p, "side").unwrap_or_else(|| "right".into()); + let parent = n.parent.ok_or_else(|| bad(cmd, "that object has no parent"))?; + let at = n.transform.translation(); + let colour_of = |n: &Node| n.fills.iter().find_map(Paint::solid_color); + let (next, color) = match shape { + None => ((STICKY, STICKY), colour_of(n)), + Some(_) => ((n.width, n.height), n.children.first().and_then(|c| doc.node(*c)).and_then(colour_of)), + }; + let color = color.unwrap_or(s.tool.board.sticky); + let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side) + .ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?; + let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?); + if editing.is_some() { + textedit::end(s)?; + } + let doc = s.doc_mut()?; + let (id, text) = match shape { + None => make_sticky(doc, parent, to_at, "", color, &author)?, + Some(shape) => make_shape(doc, parent, to_at, next, shape, "", color)?, + }; + let connector = Connector { from: End::Node { node: from, side: from_side }, to: End::Node { node: id, side: to_side }, routing }; + let line = make(doc, page, connector, CAPS)?; + s.set_selection(vec![id]); + if boolean(p, "edit") == Some(true) { + s.request_ui("board.editText", json!({"id": id})); + } + Ok(json!({"id": id, "text": text, "connector": line})) +} + /// A shape with text's layers: the frame, its outline and its text. fn make_shape( doc: &mut Document, diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 6f0641db1..6eeb28263 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -201,7 +201,7 @@ pub static KEYS: &[Binding] = &[ k(Tools, "tool.highlighter", &["U"]), k(Tools, "tool.eraser", &["E"]), k(Tools, "tool.stamp", &["W"]), - k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(), + k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one, or the next shape joined to it").both(), // View // The documentation gives both keys for the pixel grid in different places. k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0f192557e..e1f4c785a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -201,6 +201,8 @@ mod batch_undo_snapshot_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] +mod board_connected_tests; +#[cfg(test)] mod board_connector_tests; #[cfg(test)] mod board_grid_tests; diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 4a988de6a..464424632 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -39,6 +39,7 @@ status = "draft — needs native review" "Actual size (100%)" = "الحجم الفعلي (100%)" "Add a category" = "إضافة فئة" "Add a comment" = "إضافة تعليق" +"Add a connected shape" = "إضافة شكل متصل" "Add a description" = "إضافة وصف" "Add a frame (F) or a shape (R) to get started" = "أضف إطارًا (F) أو شكلًا (R) للبدء" "Add a layer or component to tile first" = "أضف طبقة أو مكوّنًا أولًا لتكراره بنمط متجاور" @@ -1907,6 +1908,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "تقريب إلى البكسلات" "arrange.spacing" = "التباعد" "arrange.tidy" = "ترتيب منظم" +"board.addConnected" = "إضافة شكل متصل" "board.connector" = "موصل" "board.ink" = "ضربة قلم التحديد" "board.label" = "تسمية الموصل" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 6e0e9fc73..d218b873a 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -34,6 +34,7 @@ status = "draft — needs native review" "Actual size (100%)" = "প্রকৃত আকার (১০০%)" "Add a category" = "একটি বিভাগ যোগ করুন" "Add a comment" = "একটি মন্তব্য যোগ করুন" +"Add a connected shape" = "সংযুক্ত আকার যোগ করুন" "Add a description" = "একটি বিবরণ যোগ করুন" "Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন" "Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন" @@ -1822,6 +1823,7 @@ status = "draft — needs native review" [commands] +"board.addConnected" = "সংযুক্ত আকার যোগ করুন" "board.connector" = "সংযোগকারী" "board.ink" = "মার্কারের টান" "board.label" = "সংযোগকারীর লেবেল" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index adfb45b7e..35a72091d 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -30,6 +30,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Skutečná velikost (100 %)" "Add a category" = "Přidat kategorii" "Add a comment" = "Přidat komentář" +"Add a connected shape" = "Přidat propojený tvar" "Add a description" = "Přidat popis" "Add a frame (F) or a shape (R) to get started" = "Začněte přidáním rámce (F) nebo tvaru (R)" "Add a layer or component to tile first" = "Nejprve přidejte vrstvu nebo komponentu k opakování" @@ -1809,6 +1810,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Zaokrouhlit na pixely" "arrange.spacing" = "Rozestupy" "arrange.tidy" = "Uspořádat" +"board.addConnected" = "Přidat propojený tvar" "board.connector" = "Spojnice" "board.ink" = "Tah fixem" "board.label" = "Popisek spojnice" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b2491dd4b..97395646d 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -31,6 +31,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Originalgröße (100 %)" "Add a category" = "Kategorie hinzufügen" "Add a comment" = "Kommentar hinzufügen" +"Add a connected shape" = "Verbundene Form hinzufügen" "Add a description" = "Beschreibung hinzufügen" "Add a frame (F) or a shape (R) to get started" = "Fügen Sie zum Start einen Rahmen (F) oder eine Form (R) hinzu" "Add a layer or component to tile first" = "Fügen Sie zuerst eine Ebene oder Komponente zum Kacheln hinzu" @@ -1767,6 +1768,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Auf Pixel runden" "arrange.spacing" = "Abstand" "arrange.tidy" = "Aufräumen" +"board.addConnected" = "Verbundene Form hinzufügen" "board.connector" = "Verbinder" "board.ink" = "Markerstrich" "board.label" = "Verbinderbeschriftung" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 7529d93b8..69e26e7d7 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -33,6 +33,7 @@ name = "English" "Actual size (100%)" = "Actual size (100%)" "Add a category" = "Add a category" "Add a comment" = "Add a comment" +"Add a connected shape" = "Add a connected shape" "Add a description" = "Add a description" "Add a frame (F) or a shape (R) to get started" = "Add a frame (F) or a shape (R) to get started" "Add a layer or component to tile first" = "Add a layer or component to tile first" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 95743f10a..bfa6c6107 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Add a connected shape" = "Añadir una forma conectada" "Add a minute" = "Añadir un minuto" "Add column" = "Añadir columna" "Add measurement" = "Añadir medición" @@ -1895,6 +1896,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Redondear a píxeles" "arrange.spacing" = "Espaciado" "arrange.tidy" = "Ordenar" +"board.addConnected" = "Añadir forma conectada" "board.connector" = "Conector" "board.ink" = "Trazo de rotulador" "board.label" = "Etiqueta del conector" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 851583efc..357a0c545 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Todellinen koko (100 %)" "Add a category" = "Lisää luokka" "Add a comment" = "Lisää kommentti" +"Add a connected shape" = "Lisää yhdistetty muoto" "Add a description" = "Lisää kuvaus" "Add a frame (F) or a shape (R) to get started" = "Aloita lisäämällä kehys (F) tai muoto (R)" "Add a layer or component to tile first" = "Lisää ensin laatoitettava taso tai komponentti" @@ -1852,6 +1853,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Pyöristä pikseleihin" "arrange.spacing" = "Välistys" "arrange.tidy" = "Järjestä tasavälein" +"board.addConnected" = "Lisää yhdistetty muoto" "board.connector" = "Yhdistin" "board.ink" = "Tussiviiva" "board.label" = "Yhdistimen nimiö" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 894f4eeac..e7787d8da 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -34,6 +34,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Taille réelle (100 %)" "Add a category" = "Ajouter une catégorie" "Add a comment" = "Ajouter un commentaire" +"Add a connected shape" = "Ajouter une forme reliée" "Add a description" = "Ajouter une description" "Add a frame (F) or a shape (R) to get started" = "Ajoutez un cadre (F) ou une forme (R) pour commencer" "Add a layer or component to tile first" = "Ajoutez d’abord un calque ou un composant à répéter en mosaïque" @@ -1853,6 +1854,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arrondir aux pixels" "arrange.spacing" = "Espacement" "arrange.tidy" = "Ranger" +"board.addConnected" = "Ajouter une forme reliée" "board.connector" = "Connecteur" "board.ink" = "Trait de marqueur" "board.label" = "Libellé du connecteur" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 56d09e3a2..00daf1893 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -8,6 +8,7 @@ name = "Bahasa Indonesia" status = "draft — needs native review" [ui] +"Add a connected shape" = "Tambahkan bentuk yang terhubung" "Relative to" = "Relatif terhadap" "Scroll wheel zoom" = "Zoom dengan roda gulir" "Trigger layer" = "Layer pemicu" @@ -1813,6 +1814,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Bulatkan ke piksel" "arrange.spacing" = "Spasi" "arrange.tidy" = "Rapikan" +"board.addConnected" = "Tambahkan bentuk terhubung" "board.connector" = "Konektor" "board.ink" = "Goresan spidol" "board.label" = "Label konektor" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index 84eccc637..c94d09826 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -33,6 +33,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Dimensione reale (100%)" "Add a category" = "Aggiungi una categoria" "Add a comment" = "Aggiungi un commento" +"Add a connected shape" = "Aggiungi una forma collegata" "Add a description" = "Aggiungi una descrizione" "Add a frame (F) or a shape (R) to get started" = "Aggiungi un frame (F) o una forma (R) per iniziare" "Add a layer or component to tile first" = "Aggiungi prima un livello o un componente da affiancare" @@ -1788,6 +1789,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arrotonda ai pixel" "arrange.spacing" = "Spaziatura" "arrange.tidy" = "Riordina" +"board.addConnected" = "Aggiungi forma collegata" "bool.exclude" = "Escludi selezione" "bool.flatten" = "Appiattisci booleano" "bool.intersect" = "Interseca selezione" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 6cc8ad2f7..fdb2774bc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -36,6 +36,7 @@ status = "draft — needs native review" "Actual size (100%)" = "実際のサイズ(100%)" "Add a category" = "カテゴリを追加" "Add a comment" = "コメントを追加" +"Add a connected shape" = "接続されたシェイプを追加" "Add a description" = "説明を追加" "Add a frame (F) or a shape (R) to get started" = "フレーム(F)またはシェイプ(R)を追加して開始" "Add a layer or component to tile first" = "先に並べるレイヤーまたはコンポーネントを追加" @@ -1833,6 +1834,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "ピクセルに丸める" "arrange.spacing" = "間隔" "arrange.tidy" = "整列" +"board.addConnected" = "接続されたシェイプを追加" "board.connector" = "コネクタ" "board.ink" = "マーカーの線" "board.label" = "コネクタのラベル" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 4014462b1..94edaac5b 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -36,6 +36,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Actual size (100%)" = "რეალური ზომა (100%)" "Add a category" = "კატეგორიის დამატება" "Add a comment" = "კომენტარის დამატება" +"Add a connected shape" = "დაკავშირებული ფიგურის დამატება" "Add a description" = "აღწერის დამატება" "Add a frame (F) or a shape (R) to get started" = "დასაწყებად დაამატეთ ჩარჩო (F) ან ფიგურა (R)" "Add a layer or component to tile first" = "ჯერ დაამატეთ შრე ან კომპონენტი ფილებად დასაწყობად" @@ -1905,6 +1906,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "arrange.roundToPixels" = "პიქსელებამდე დამრგვალება" "arrange.spacing" = "ინტერვალი" "arrange.tidy" = "მოწესრიგება" +"board.addConnected" = "დაკავშირებული ფიგურის დამატება" "board.connector" = "შემაერთებელი" "board.ink" = "მარკერის შტრიხი" "board.label" = "შემაერთებლის წარწერა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 681eb524b..1f11b0bf9 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -32,6 +32,7 @@ status = "draft — needs native review" "Actual size (100%)" = "실제 크기(100%)" "Add a category" = "카테고리 추가" "Add a comment" = "댓글 추가" +"Add a connected shape" = "연결된 도형 추가" "Add a description" = "설명 추가" "Add a frame (F) or a shape (R) to get started" = "프레임(F) 또는 도형(R)을 추가해 시작하세요" "Add a layer or component to tile first" = "먼저 바둑판식으로 배열할 레이어나 컴포넌트를 추가하세요" @@ -1873,6 +1874,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "픽셀에 맞춰 반올림" "arrange.spacing" = "간격" "arrange.tidy" = "정돈" +"board.addConnected" = "연결된 도형 추가" "board.connector" = "커넥터" "board.ink" = "마커 획" "board.label" = "커넥터 레이블" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 89a993346..e3bfefe6f 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Werkelijke grootte (100%)" "Add a category" = "Categorie toevoegen" "Add a comment" = "Reactie toevoegen" +"Add a connected shape" = "Verbonden vorm toevoegen" "Add a description" = "Beschrijving toevoegen" "Add a frame (F) or a shape (R) to get started" = "Voeg een frame (F) of een vorm (R) toe om te beginnen" "Add a layer or component to tile first" = "Voeg eerst een laag of component toe om als tegel te gebruiken" @@ -1851,6 +1852,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Afronden op pixels" "arrange.spacing" = "Afstand" "arrange.tidy" = "Opruimen" +"board.addConnected" = "Verbonden vorm toevoegen" "board.connector" = "Verbinding" "board.ink" = "Stiftlijn" "board.label" = "Label van verbinding" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 2081b1cd5..f66cb7db6 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -33,6 +33,7 @@ name = "Polski" "Actual size (100%)" = "Rozmiar rzeczywisty (100%)" "Add a category" = "Dodaj kategorię" "Add a comment" = "Dodaj komentarz" +"Add a connected shape" = "Dodaj połączony kształt" "Add a description" = "Dodaj opis" "Add a frame (F) or a shape (R) to get started" = "Dodaj ramkę (F) lub kształt (R), aby rozpocząć" "Add a layer or component to tile first" = "Najpierw dodaj warstwę lub komponent do powielenia" @@ -1864,6 +1865,7 @@ name = "Polski" "arrange.roundToPixels" = "Zaokrąglij do pikseli" "arrange.spacing" = "Odstępy" "arrange.tidy" = "Uporządkuj" +"board.addConnected" = "Dodaj połączony kształt" "board.connector" = "Łącznik" "board.ink" = "Pociągnięcie markera" "board.label" = "Etykieta łącznika" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 4033ecc8e..84f03f1f7 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -30,6 +30,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Tamanho real (100%)" "Add a category" = "Adicionar uma categoria" "Add a comment" = "Adicione um comentário" +"Add a connected shape" = "Adicionar uma forma conectada" "Add a description" = "Adicionar uma descrição" "Add a frame (F) or a shape (R) to get started" = "Adicione um quadro (F) ou uma forma (R) para começar" "Add a layer or component to tile first" = "Adicione primeiro uma camada ou um componente para o mosaico" @@ -1870,6 +1871,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arredondar para pixels" "arrange.spacing" = "Espaçamento" "arrange.tidy" = "Organizar" +"board.addConnected" = "Adicionar forma conectada" "board.connector" = "Conector" "board.ink" = "Traço de marcador" "board.label" = "Rótulo do conector" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 01c2813a5..24683d56c 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -33,6 +33,7 @@ name = "Русский" "Actual size (100%)" = "Фактический размер (100%)" "Add a category" = "Добавить категорию" "Add a comment" = "Добавить комментарий" +"Add a connected shape" = "Добавить связанную фигуру" "Add a description" = "Добавить описание" "Add a frame (F) or a shape (R) to get started" = "Для начала добавьте Frame (F) или фигуру (R)" "Add a layer or component to tile first" = "Сначала добавьте слой или компонент для повторения" @@ -1864,6 +1865,7 @@ name = "Русский" "arrange.roundToPixels" = "Округлить до пикселей" "arrange.spacing" = "Интервал" "arrange.tidy" = "Упорядочить" +"board.addConnected" = "Добавить связанную фигуру" "board.connector" = "Соединитель" "board.ink" = "Штрих маркера" "board.label" = "Подпись соединителя" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index e18044f52..0fa4f906c 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -33,6 +33,7 @@ name = "Svenska" "Actual size (100%)" = "Verklig storlek (100 %)" "Add a category" = "Lägg till en kategori" "Add a comment" = "Lägg till en kommentar" +"Add a connected shape" = "Lägg till en ansluten form" "Add a description" = "Lägg till en beskrivning" "Add a frame (F) or a shape (R) to get started" = "Lägg till en frame (F) eller en form (R) för att komma igång" "Add a layer or component to tile first" = "Lägg först till ett lager eller en komponent att lägga sida vid sida" @@ -1935,6 +1936,7 @@ name = "Svenska" "arrange.roundToPixels" = "Avrunda till pixlar" "arrange.spacing" = "Avstånd" "arrange.tidy" = "Snygga till" +"board.addConnected" = "Lägg till ansluten form" "board.connector" = "Koppling" "board.ink" = "Märkpennsdrag" "board.label" = "Kopplingsetikett" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 4af404982..58a6422ec 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -38,6 +38,7 @@ status = "draft — needs native review" "Actual size (100%)" = "ขนาดจริง (100%)" "Add a category" = "เพิ่มหมวดหมู่" "Add a comment" = "เพิ่มความคิดเห็น" +"Add a connected shape" = "เพิ่มรูปทรงที่เชื่อมต่อ" "Add a description" = "เพิ่มคำอธิบาย" "Add a frame (F) or a shape (R) to get started" = "เพิ่มเฟรม (F) หรือรูปร่าง (R) เพื่อเริ่มต้น" "Add a layer or component to tile first" = "เพิ่มเลเยอร์หรือคอมโพเนนต์ที่จะเรียงต่อกันก่อน" @@ -1838,6 +1839,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "ปัดเป็นพิกเซล" "arrange.spacing" = "ระยะห่าง" "arrange.tidy" = "จัดระเบียบ" +"board.addConnected" = "เพิ่มรูปทรงที่เชื่อมต่อ" "board.connector" = "ตัวเชื่อม" "board.ink" = "เส้นปากกามาร์กเกอร์" "board.label" = "ป้ายตัวเชื่อม" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 6f84aa376..064e853a0 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -34,6 +34,7 @@ name = "Türkçe" "Actual size (100%)" = "Gerçek boyut (%100)" "Add a category" = "Kategori ekle" "Add a comment" = "Yorum ekle" +"Add a connected shape" = "Bağlı şekil ekle" "Add a description" = "Açıklama ekle" "Add a frame (F) or a shape (R) to get started" = "Başlamak için bir çerçeve (F) veya bir şekil (R) ekleyin" "Add a layer or component to tile first" = "Önce döşenecek bir katman veya bileşen ekleyin" @@ -1813,6 +1814,7 @@ name = "Türkçe" "arrange.roundToPixels" = "Piksellere yuvarla" "arrange.spacing" = "Aralık" "arrange.tidy" = "Toparla" +"board.addConnected" = "Bağlı şekil ekle" "board.connector" = "Bağlayıcı" "board.ink" = "Keçeli kalem çizgisi" "board.label" = "Bağlayıcı etiketi" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index e729d8cc0..2c12c4be7 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -34,6 +34,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Kích thước thực (100%)" "Add a category" = "Thêm danh mục" "Add a comment" = "Thêm bình luận" +"Add a connected shape" = "Thêm hình được nối" "Add a description" = "Thêm mô tả" "Add a frame (F) or a shape (R) to get started" = "Thêm khung (F) hoặc hình dạng (R) để bắt đầu" "Add a layer or component to tile first" = "Hãy thêm một lớp hoặc thành phần để lặp ô trước" @@ -1789,6 +1790,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Làm tròn theo pixel" "arrange.spacing" = "Khoảng cách" "arrange.tidy" = "Sắp xếp gọn" +"board.addConnected" = "Thêm hình được nối" "bool.exclude" = "Loại trừ vùng chọn" "bool.flatten" = "Làm phẳng nhóm boolean" "bool.intersect" = "Giao vùng chọn" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index ae545b2f7..3351632d4 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -33,6 +33,7 @@ status = "draft — needs native review" "Actual size (100%)" = "实际大小(100%)" "Add a category" = "添加类别" "Add a comment" = "添加评论" +"Add a connected shape" = "添加相连的形状" "Add a description" = "添加描述" "Add a frame (F) or a shape (R) to get started" = "添加画框(F)或形状(R)以开始" "Add a layer or component to tile first" = "请先添加要平铺的图层或组件" @@ -1766,6 +1767,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "取整到像素" "arrange.spacing" = "间距" "arrange.tidy" = "整理" +"board.addConnected" = "添加相连的形状" "board.connector" = "连接线" "board.ink" = "马克笔笔画" "board.label" = "连接线标签" diff --git a/crates/ui-egui/src/board_connect_tests.rs b/crates/ui-egui/src/board_connect_tests.rs new file mode 100644 index 000000000..46667a65a --- /dev/null +++ b/crates/ui-egui/src/board_connect_tests.rs @@ -0,0 +1,103 @@ +//! The "+" handles beside a board's selected sticky or shape (#1597), drawn headless: four, only +//! for one sticky or shape with the Move tool, clear of the bar above it; clicking one makes the +//! next shape on that side joined by a connector and starts typing in it; one undo takes both away. + +use egui::{Event, Id, LayerId, Modifiers, Order, PointerButton, Pos2, Rect, pos2}; +use figcraft_doc::{BoardItem, NodeId}; +use figcraft_engine::Session; +use figcraft_engine::tools::ToolId; +use serde_json::{Value, json}; + +use crate::board_connect::REACH; +use crate::layers_drag_tests::Ui; + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn board_with_shape() -> (Ui, NodeId) { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + let a = id(&s.execute("board.shape", &json!({"shape": "roundedRectangle", "x": 0, "y": 0, "width": 160, "height": 100})).unwrap()); + let mut u = Ui::new(s); + u.frame(vec![]); + // The shape in the middle of the canvas at 100%. + let view = &mut u.app.session.ds_mut().unwrap().view; + (view.zoom, view.cx, view.cy) = (1.0, 80.0, 50.0); + u.frame(vec![]); + (u, a) +} + +/// Where the "+" for a side shows, if it does. +fn handle(u: &Ui, side: &str) -> Option { + let layer = LayerId::new(Order::Middle, Id::unique(("board_connect", side))); + u.ctx.memory(|m| m.areas().visible_last_frame(&layer)).then(|| u.ctx.memory(|m| m.area_rect(layer.id))).flatten() +} + +fn click(u: &mut Ui, p: Pos2) { + for pressed in [true, false] { + u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +fn screen_box(u: &Ui, n: NodeId) -> Rect { + let b = u.app.session.doc().unwrap().world_bounds(n).unwrap(); + Rect::from_two_pos(u.app.to_screen(b.x0, b.y0), u.app.to_screen(b.x1, b.y1)) +} + +#[test] +fn four_handles_show_for_one_shape_and_clear_of_the_bar() { + let (mut u, a) = board_with_shape(); + u.app.session.execute("select.set", &json!({"ids": []})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + assert!(handle(&u, "right").is_none(), "nothing selected"); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let r = screen_box(&u, a); + for (side, want) in [ + ("above", pos2(r.center().x, r.min.y - REACH)), + ("right", pos2(r.max.x + REACH, r.center().y)), + ("below", pos2(r.center().x, r.max.y + REACH)), + ("left", pos2(r.min.x - REACH, r.center().y)), + ] { + let h = handle(&u, side).unwrap_or_else(|| panic!("{side}: no handle")); + assert!((h.center() - want).length() < 1.0, "{side}: {h:?}"); + } + let bar = u.ctx.memory(|m| m.area_rect(Id::unique("board_bar"))).expect("the bar shows"); + assert!(!bar.intersects(handle(&u, "above").unwrap()), "the bar leaves the handle above free"); + // Another tool, or two layers selected: none. + u.app.session.set_tool(ToolId::Hand); + u.frame(vec![]); + u.frame(vec![]); + assert!(handle(&u, "right").is_none(), "another tool"); + u.app.session.set_tool(ToolId::Move); + let b = id(&u.app.session.execute("board.sticky", &json!({"x": 600, "y": 0})).unwrap()); + u.app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + assert!(handle(&u, "right").is_none(), "two layers"); +} + +#[test] +fn clicking_a_handle_adds_the_next_shape_joined_and_typing() { + let (mut u, a) = board_with_shape(); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let before = u.app.session.doc().unwrap().ids().count(); + let at = handle(&u, "below").expect("a handle below").center(); + click(&mut u, at); + let doc = u.app.session.doc().unwrap(); + let made: Vec = doc.ids().filter(|i| doc.node(*i).is_some_and(|n| n.board.is_some())).collect(); + let next = made.iter().copied().find(|i| *i != a && matches!(doc.node(*i).unwrap().board, Some(BoardItem::Shape { .. }))).expect("a new shape"); + let line = made.iter().copied().find(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Connector(_)))).expect("a connector"); + assert!(screen_box(&u, next).min.y > screen_box(&u, a).max.y, "below it"); + assert!(matches!(&doc.node(line).unwrap().board, Some(BoardItem::Connector(c)) if c.from.node() == Some(a) && c.to.node() == Some(next))); + assert!(u.app.session.ds().unwrap().text_edit.is_some(), "typing in the new shape"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(u.app.session.doc().unwrap().ids().count(), before, "undo takes the shape and its connector away"); +} diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 7926dfd2d..c507f4111 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -215,6 +215,23 @@ fn the_next_sticky_from_the_keyboard_goes_on_typing() { assert_eq!(doc.node(owner).unwrap().transform.translation().x, 224.0); } +#[test] +fn the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing() { + let mut u = board(); + let a = id(&u.app.run("board.shape", json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap()); + // Shift+Cmd/Ctrl+Enter on a shape: the next shape, joined to it (#1597). + crate::menus::invoke(&mut u.app, "board.nextSticky"); + let doc = u.app.session.doc().unwrap(); + let te = u.app.session.ds().unwrap().text_edit.clone().expect("typing in the new shape"); + let b = figcraft_engine::board::owner_of_text(doc, te.id).unwrap(); + assert!(matches!(doc.node(b).unwrap().board, Some(BoardItem::Shape { shape: BoardShape::Diamond }))); + let joined = doc.nodes().any(|n| { + matches!(&n.board, Some(BoardItem::Connector(k)) if k.from == figcraft_doc::End::Node { node: a, side: figcraft_doc::Side::Right } + && k.to == figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Left }) + }); + assert!(joined, "a connector from the first shape's right side to the new one's left"); +} + #[test] fn a_board_page_has_a_dot_grid_under_its_layers_thinned_out_zoomed_out() { let mut u = board(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ae39c7e0e..e63caaa3a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -31,6 +31,7 @@ use render::{JobKey, request_render, shows}; pub use rulers::fmt; pub(crate) use rulers::{status_badge, status_label}; pub mod board_bar; +pub mod board_connect; pub mod board_facilitate; pub mod comments; pub mod cursors; @@ -299,6 +300,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::board_facilitate::badges(app, &painter); crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p))); crate::board_bar::show(app, &ctx); + crate::board_connect::show(app, &ctx); crate::board_facilitate::show(app, &ctx, rect); crate::widget_bar::show(app, &ctx); crate::selection_spacing::draw(app, &painter); diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs index 8a33538c3..418731b51 100644 --- a/crates/ui-egui/src/canvas/board_bar.rs +++ b/crates/ui-egui/src/canvas/board_bar.rs @@ -172,7 +172,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } let Some(h) = has(app) else { return }; let Some(b) = app.session.ds().ok().and_then(|ds| ds.doc.bounds_of(&ds.selection)) else { return }; - let sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + let mut sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + // Clear of the "+" handles above and below a sticky or shape. + if crate::board_connect::shown(app) { + sel = sel.expand2(vec2(0.0, crate::board_connect::REACH)); + } let size = vec2(h.width(), 40.0); let at = place(sel, size, app.canvas.rect); let t = Tokens::get(ctx); diff --git a/crates/ui-egui/src/canvas/board_connect.rs b/crates/ui-egui/src/canvas/board_connect.rs new file mode 100644 index 000000000..87211055b --- /dev/null +++ b/crates/ui-egui/src/canvas/board_connect.rs @@ -0,0 +1,115 @@ +//! Boards: a "+" beside each side of the one selected sticky or shape (#1597). Hovering one shows +//! where the next one goes; clicking it makes the next of the same kind there, joined by a +//! connector, and starts typing in it (`board.addConnected`). + +use egui::{Area, Color32, CornerRadius, Id, LayerId, Order, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; +use figcraft_doc::{BoardItem, NodeId}; +use figcraft_engine::ToolId; +use figcraft_engine::board::{STICKY, connected_place}; +use serde_json::json; + +use crate::FigApp; +use crate::i18n::tr; +use crate::theme::Tokens; + +/// How far each "+" sits outside the object, from its edge to its centre (screen points): clear +/// of the selection's handles and of the size label under it. +pub(crate) const REACH: f32 = 36.0; +const RADIUS: f32 = 9.0; +const SIDES: [&str; 4] = ["above", "right", "below", "left"]; + +/// The sticky or shape the handles are for: its id, its box on the page and the next one's size. +fn target(app: &FigApp) -> Option<(NodeId, figcraft_geom::Rect, (f64, f64))> { + let editable = app.session.active_read_only().is_none(); + if app.session.tool.busy() || !editable || app.session.tool.tool != ToolId::Move || !app.session.doc().is_ok_and(figcraft_engine::board::is_board) + { + return None; + } + let ds = app.session.ds().ok()?; + let ([id], None) = (ds.selection.as_slice(), &ds.text_edit) else { return None }; + let n = ds.doc.node(*id)?; + let next = match n.board { + Some(BoardItem::Sticky { .. }) => (STICKY, STICKY), + Some(BoardItem::Shape { .. }) => (n.width, n.height), + _ => return None, + }; + Some((*id, ds.doc.world_bounds(*id)?, next)) +} + +/// Handles show for the selection: the bar above it leaves them room. +pub(crate) fn shown(app: &FigApp) -> bool { + target(app).is_some() +} + +/// The centre of the "+" for `side` of the object's screen box `r`. +fn handle_at(r: Rect, side: &str) -> Pos2 { + match side { + "above" => pos2(r.center().x, r.min.y - REACH), + "right" => pos2(r.max.x + REACH, r.center().y), + "below" => pos2(r.center().x, r.max.y + REACH), + _ => pos2(r.min.x - REACH, r.center().y), + } +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + let Some((id, b, next)) = target(app) else { return }; + let r = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + // Too small on screen to tell the handles from the object. + if r.width() < 2.0 * RADIUS || r.height() < 2.0 * RADIUS { + return; + } + let t = Tokens::get(ctx); + let accent = crate::theme::canvas_accent(ctx); + // Not over the toolbar at the bottom of the canvas. + let toolbar = crate::toolbar::pill_rect(app.canvas.rect, crate::toolbar::bar_width(app)).expand(RADIUS); + let mut picked = None; + for side in SIDES { + let c = handle_at(r, side); + if !app.canvas.rect.shrink(RADIUS).contains(c) || toolbar.contains(c) { + continue; + } + let area = Area::new(Id::unique(("board_connect", side))).fixed_pos(c - vec2(RADIUS, RADIUS)).order(Order::Middle); + let clicked = area + .show(ctx, |ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(2.0 * RADIUS, 2.0 * RADIUS), Sense::click()); + let hot = resp.hovered(); + let p = ui.painter(); + p.circle_filled(rect.center(), RADIUS, if hot { accent } else { t.panel }); + p.circle_stroke(rect.center(), RADIUS, Stroke::new(1.0, if hot { accent } else { t.border })); + let ink = Stroke::new(1.5, if hot { Color32::WHITE } else { t.text_secondary }); + let arm = RADIUS * 0.45; + p.line_segment([rect.center() - vec2(arm, 0.0), rect.center() + vec2(arm, 0.0)], ink); + p.line_segment([rect.center() - vec2(0.0, arm), rect.center() + vec2(0.0, arm)], ink); + if hot { + preview(app, ctx, b, next, side, accent); + } + resp.on_hover_text(tr("Add a connected shape")).clicked() + }) + .inner; + if clicked { + picked = Some(side); + } + } + if let Some(side) = picked { + app.run("board.addConnected", json!({"id": id, "side": side, "edit": true})); + } +} + +/// Where the next one goes for `side`, faintly: its box and the connector's line to it. +fn preview(app: &FigApp, ctx: &egui::Context, b: figcraft_geom::Rect, next: (f64, f64), side: &str, accent: Color32) { + let Some(((x, y), ..)) = connected_place((b.x0, b.y0), (b.width(), b.height()), next, side) else { return }; + let ghost = Rect::from_two_pos(app.to_screen(x, y), app.to_screen(x + next.0, y + next.1)); + let from = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + let (a, z) = match side { + "above" => (from.center_top(), ghost.center_bottom()), + "right" => (from.right_center(), ghost.left_center()), + "below" => (from.center_bottom(), ghost.center_top()), + _ => (from.left_center(), ghost.right_center()), + }; + let p = ctx.layer_painter(LayerId::new(Order::Middle, Id::unique("board_connect_preview"))); + let faint = Stroke::new(1.5, accent.gamma_multiply(0.6)); + p.rect_filled(ghost, CornerRadius::same(8), accent.gamma_multiply(0.08)); + let corners = [ghost.left_top(), ghost.right_top(), ghost.right_bottom(), ghost.left_bottom(), ghost.left_top()]; + p.extend(Shape::dashed_line(&corners, faint, 6.0, 4.0)); + p.line_segment([a, z], faint); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3aa8baa10..751f01f12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod ui_scale; pub mod widgets; pub mod windows; +#[cfg(test)] +mod board_connect_tests; #[cfg(test)] mod board_picker_tests; #[cfg(test)] @@ -257,6 +259,7 @@ mod windows_qa_tests; #[cfg(test)] mod windows_tests; pub use canvas::board_bar; +pub use canvas::board_connect; pub use canvas::board_facilitate; pub use canvas::comments; pub use canvas::cursors; diff --git a/docs/boards-parity.md b/docs/boards-parity.md index fd2ce41cc..b9024e94b 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -35,6 +35,7 @@ all end with the same geometry. | Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | | Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | +| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own | | Quick-create handles (drag out a connected copy) | missing | | @@ -63,6 +64,8 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | #1597: Shift+Cmd/Ctrl+Enter on a selected shape adds the next shape joined by a connector and starts typing in it; on a sticky it still adds a sticky beside it. Numbers unchanged. | +| 2026-10-30 | minor | A row for creating the next shape already connected: "+" handles on a selected sticky's or shape's sides make the next one joined by a connector, ready for typing (#1597). Numbers unchanged | | 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). | | 2026-10-29 | minor | M12: board grid settings (`board.grid`): dots, lines, graph paper or none, with spacing, size and contrast, in Preferences; the default grid looks as before. Numbers unchanged (beyond the incumbent's behaviour) | | 2026-10-29 | minor | M12, #1387 (connector tool): while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 44c68c5d9..24a6773d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -457,7 +457,13 @@ one undo step and returns the ids it made: defaults to your name (`pref.userName`, or the one a live session gave you; `false` for none). - `board.nextSticky {id?, side?: right|below|left|above, edit?}` → `{id, text}`: a sticky of the same colour beside the selected (or edited) one; with `edit: true` the app goes on typing in it - (`Shift+Cmd/Ctrl+Enter` in the app does both). + (`Shift+Cmd/Ctrl+Enter` in the app does both). On a shape it does what `board.addConnected` does: + the next shape, joined to it by a connector (`{id, text, connector}`). +- `board.addConnected {id?, side?: right|below|left|above, edit?}` → `{id, text, connector}`: the + next sticky or shape of the same kind, size and colour on that side of the selected (or edited) + one, 80 units away, joined to it by a connector the way the Connector tool makes one; one undo + step. With `edit: true` the app goes on typing in it (the "+" beside a selected sticky or shape + in the app does both). - `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred, wrapping text: `rectangle roundedRectangle ellipse diamond triangle speechBubble chevron`. - `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow|curved, startCap?, endCap?, label?}` diff --git a/docs/roadmap.md b/docs/roadmap.md index 1aacb7325..dcfeb15f4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,8 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, #1597: Shift+Cmd/Ctrl+Enter on a board shape adds the next shape already connected (stickies unchanged). Tests: `board_connected_tests`, `board_ui_tests` | +| 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | | 2026-10-30 | minor | M8, #1085 (the rest): a panic in a `Session::grouped` closure outside any command (plugin, widget, data fill and CLI groups) skipped the group rollback, leaving documents half edited, the history limit unbounded, live documents held and the group depth raised. `checkpointed` now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the outer guard still reports it. Journal rollover and the browser (wasm aborts) are unchanged. 3 regression tests (`group_panic_tests`). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged |