diff --git a/Cargo.lock b/Cargo.lock index 40a16fe7..24536ff9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,7 +855,20 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] @@ -871,6 +884,28 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "foreign-types", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -1782,10 +1817,12 @@ dependencies = [ name = "figcraft-text" version = "0.11.2" dependencies = [ + "core-text", "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", "log", + "objc2 0.6.5", "png", "skrifa 0.47.0", "unicode-bidi", @@ -5982,7 +6019,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", diff --git a/README.md b/README.md index 778f558f..2e91506a 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,16 @@

Live sessions: three apps on one figcraft-server file, with cursors, selections, cursor chat and spotlight

+ + + A board file named Sprint retro on a dot grid: a Sprint 14 retro made from the retrospective template, with Went well, To improve and Next steps sections of sticky notes, vote counts of 3, 2, 1 and 1 on four stickies after a vote has ended, and below it a pull request flow chart from the flow chart template whose shapes are joined by straight and elbow connectors with Yes and No labels; a timer pill in the top right shows 4:30 left with pause, add a minute, stop and Results buttons +

Boards: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions

+ + + Dev Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu +

Dev Mode measurements: redlines placed between layers or along a side, saved in the file and following the layers · examples/ridgeline-app.figcraft

+ + ## Made in FigCraft diff --git a/ROADMAP.md b/ROADMAP.md index 40ffa470..e403b02d 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-25 · **Change:** minor (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-25 · **Change:** minor (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 @@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~155–280 h** one agent | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | | Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | @@ -65,7 +65,7 @@ native speakers' reviews of the nine language drafts need people. | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | | Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | -| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | +| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | ## Features @@ -89,11 +89,11 @@ native speakers' reviews of the nine language drafts need people. | Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | -| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | +| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | -| Boards (whiteboarding; not in the weighted numbers) | ~37% | ~21% | 28–56 | [boards-parity](docs/boards-parity.md) | +| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) | ## Languages @@ -137,6 +137,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. +- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). +- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged. +- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run + unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes + into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget + can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected + (`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets + 65%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -344,6 +352,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | +| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | +| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | +| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | +| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 31b877cb..1ea420a5 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -658,7 +658,10 @@ impl Store { let dir = self.dir(&id); let written = fs::create_dir_all(&dir) .map_err(|e| e.to_string()) - .and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())) + .and_then(|()| { + disk::sync_dir(Some(&self.root.join(FILES))); + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) + }) .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) // Last: a folder without its description is an upload cut short. diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index b273ba34..7977f83a 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -223,6 +223,10 @@ pub enum DocProp { VariableOrder, /// A design file or a board. Kind, + /// A board's countdown (`null`: none). + Timer, + /// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s. + Vote, } /// A document-level entry, by id. @@ -242,6 +246,8 @@ pub enum Entry { DevSnapshot(NodeId), /// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64). Image(String), + /// A voter's ballot in a board's vote (value: the layer ids voted on). + Ballot(String), } /// One synced value a change writes: a node property, a document-level property or a @@ -315,7 +321,7 @@ impl Entry { Entry::Variable(id) => Some(id_space(id, VARIABLE_ID_PREFIX)), Entry::Collection(id) => Some(id_space(id, COLLECTION_ID_PREFIX)), Entry::Comment(id) => Some(space_of(*id)), - Entry::DevSnapshot(_) | Entry::Image(_) => None, + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None, } } @@ -328,6 +334,7 @@ impl Entry { Entry::Variable(id) => format!("variable {id}"), Entry::DevSnapshot(id) => format!("ready-for-dev snapshot of {id}"), Entry::Image(hash) => format!("image {hash}"), + Entry::Ballot(voter) => format!("ballot of {voter}"), } } @@ -338,7 +345,7 @@ impl Entry { Entry::Comment(_) => Some(Family::Comments), Entry::Collection(_) => Some(Family::Collections), Entry::Variable(_) => Some(Family::Variables), - Entry::DevSnapshot(_) | Entry::Image(_) => None, + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None, } } } diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index e4ff32c2..24a604ff 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -211,6 +211,8 @@ doc_scalars! { AnnotationCategories => annotation_categories, Libraries => libraries, Kind => kind, + Timer => timer, + Vote => vote, } /// Keyed maps (no order): changed and added entries are put, missing ones removed. @@ -250,7 +252,8 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec) -> Result< |k| Entry::Image(k.clone()), image_json, out, - ) + )?; + diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out) } /// Apply a document-level change. Returns whether it can change which pages are listed (the @@ -286,6 +289,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result let img = image_of(value)?; doc.images.insert(k.clone(), img); } + Entry::Ballot(k) => { + doc.votes.insert(k.clone(), value.read("ballot")?); + } }, Change::Remove { entry } => match entry { Entry::Style(k) => STYLES.remove(doc, k), @@ -298,6 +304,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result Entry::Image(k) => { doc.images.remove(k); } + Entry::Ballot(k) => { + doc.votes.remove(k); + } }, _ => return Err(Error::Invalid("not a document-level change".into())), } @@ -334,6 +343,7 @@ pub(crate) fn entry_value(doc: &Document, entry: &Entry) -> Result, Entry::Variable(k) => doc.variables.variables.iter().find(|v| v.id == *k).map(Json::of), Entry::DevSnapshot(k) => doc.dev_snapshots.get(k).map(|s| Json::of(s.as_ref())), Entry::Image(k) => doc.images.get(k).map(image_json), + Entry::Ballot(k) => doc.votes.get(k).map(Json::of), }; value.transpose() } @@ -347,6 +357,7 @@ pub(crate) fn has(doc: &Document, entry: &Entry) -> bool { Entry::Variable(k) => doc.variables.variables.iter().any(|v| v.id == *k), Entry::DevSnapshot(k) => doc.dev_snapshots.contains_key(k), Entry::Image(k) => doc.images.contains_key(k), + Entry::Ballot(k) => doc.votes.contains_key(k), } } diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index acd40397..9c21a56a 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -92,6 +92,7 @@ impl fmt::Display for Key { Entry::Variable(k) => write!(f, "variable/{k}"), Entry::DevSnapshot(k) => write!(f, "devSnapshot/{k}"), Entry::Image(k) => write!(f, "image/{k}"), + Entry::Ballot(k) => write!(f, "ballot/{k}"), }, } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 7ce1df5e..0b7ae704 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -195,7 +195,14 @@ impl Color { /// CSS `rgba(...)` / hex text for copy-as-CSS. pub fn to_css(self) -> String { let [r, g, b, _] = self.to_rgba8(); - if self.a >= 0.999 { self.to_css_hex() } else { format!("rgba({r}, {g}, {b}, {:.2})", self.a) } + if self.a >= 0.999 { + return self.to_css_hex(); + } + // Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours). + let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8; + let short = format!("{:.2}", self.a); + let alpha = if short.parse::().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) }; + format!("rgba({r}, {g}, {b}, {alpha})") } /// Linear interpolation in sRGB (as gradients render). diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 6fedad83..2b628b71 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -133,3 +133,11 @@ fn malformed_alpha_or_extra_args_rejected() { assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0); assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5); } + +#[test] +fn to_css_round_trips_every_alpha() { + for a in 0..=255u8 { + let c = Color::from_rgba8(10, 20, 30, a); + assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8(), c.to_rgba8(), "alpha {a}: {}", c.to_css()); + } +} diff --git a/crates/doc/src/board.rs b/crates/doc/src/board.rs index ab943d2a..f7367db3 100644 --- a/crates/doc/src/board.rs +++ b/crates/doc/src/board.rs @@ -57,13 +57,16 @@ pub enum BoardItem { }, /// A marker or highlighter stroke (what the eraser takes). Ink, + /// A table: a frame of row frames holding cell frames with text (its column lines are thin + /// rectangles laid over it). + Table, } impl BoardItem { /// Objects selected and moved as a whole: a click on their text or outline picks them, and /// they never take other layers in. pub fn is_atom(&self) -> bool { - matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. }) + matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table) } /// Objects whose first text layer is their text (a double-click or Enter edits it). @@ -109,6 +112,7 @@ impl BoardItem { BoardItem::Label { .. } => "label", BoardItem::Stamp { .. } => "stamp", BoardItem::Ink => "ink", + BoardItem::Table => "table", } } } diff --git a/crates/doc/src/facilitation.rs b/crates/doc/src/facilitation.rs new file mode 100644 index 00000000..fd3ae1d2 --- /dev/null +++ b/crates/doc/src/facilitation.rs @@ -0,0 +1,111 @@ +//! A board's facilitation state, shared by everyone on it: the countdown timer and the vote. +//! +//! The timer keeps when it ends (or, paused, how long is left), never the ticks: each client works +//! out the time left from its own clock. A vote's ballots are kept per voter, so two people voting +//! at once change different entries. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{BoardItem, Document, NodeId}; + +/// The longest a timer runs (24 hours, in milliseconds). +pub const TIMER_MAX_MS: u64 = 24 * 60 * 60 * 1000; +/// The most votes one person gets in a vote. +pub const VOTES_MAX: u32 = 99; + +/// A board's countdown. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum BoardTimer { + /// Counting down to `ends` (milliseconds since the Unix epoch). + Running { ends: u64 }, + /// Stopped with `left` milliseconds to go. + Paused { left: u64 }, +} + +impl BoardTimer { + /// Milliseconds left at `now` (ms since the Unix epoch); 0 once it has run out. + pub fn left(&self, now: u64) -> u64 { + match *self { + BoardTimer::Running { ends } => ends.saturating_sub(now).min(TIMER_MAX_MS), + BoardTimer::Paused { left } => left.min(TIMER_MAX_MS), + } + } +} + +/// What a vote is on. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum VoteScope { + /// Any object on the board. + #[default] + All, + /// Sticky notes only. + Stickies, + /// The objects that were selected when it started. + Only { ids: Vec }, +} + +impl VoteScope { + /// The object a vote at layer `leaf` goes to: the nearest layer from it up that the vote is + /// on (`All`: `whole`, the object a click there picks). + pub fn target(&self, doc: &Document, leaf: NodeId, whole: NodeId) -> Option { + if matches!(self, VoteScope::All) { + return self.takes(doc, whole).then_some(whole); + } + std::iter::once(leaf).chain(doc.ancestors(leaf)).find(|id| self.takes(doc, *id)) + } + + /// Whether object `id` takes votes. + pub fn takes(&self, doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + if doc.pages.contains(&id) || n.parent.is_none() { + return false; + } + match self { + VoteScope::All => true, + VoteScope::Stickies => matches!(n.board, Some(BoardItem::Sticky { .. })), + VoteScope::Only { ids } => ids.contains(&id), + } + } +} + +/// A vote: how many votes each person has, what they go on, and whether it has ended (then +/// everyone's votes show). The ballots are [`Document::votes`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Vote { + pub per_person: u32, + #[serde(default)] + pub scope: VoteScope, + #[serde(default)] + pub ended: bool, + /// Who started it. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub by: String, +} + +/// Every voter's ballot: the objects they put their votes on (once per vote). +pub type Ballots = BTreeMap>; + +impl Vote { + /// The votes of `ballot` that count: on objects that are still there, up to the cap. + pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator + 'a { + ballot.iter().copied().filter(|id| doc.node(*id).is_some()).take(self.per_person.min(VOTES_MAX) as usize) + } + + /// The votes each object got, most first (ties in layer id order). + pub fn tally(&self, doc: &Document, ballots: &Ballots) -> Vec<(NodeId, u32)> { + let mut counts: BTreeMap = BTreeMap::new(); + for ballot in ballots.values() { + for id in self.counted(doc, ballot) { + *counts.entry(id).or_default() += 1; + } + } + let mut out: Vec<(NodeId, u32)> = counts.into_iter().collect(); + out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + out + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d1baebf6..80eada04 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ pub mod board; pub mod colors; pub mod comments; +pub mod facilitation; pub mod geometry; pub mod handoff; pub mod ids; @@ -27,6 +28,7 @@ pub mod vector_regions; pub use board::*; pub use colors::*; pub use comments::*; +pub use facilitation::*; pub use figcraft_color as color; pub use figcraft_geom as geom; pub use handoff::*; @@ -208,6 +210,15 @@ pub struct Document { /// [`handoff`]). #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub dev_snapshots: BTreeMap>, + /// A board's countdown (see [`facilitation`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timer: Option, + /// A board's vote, running or ended. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vote: Option, + /// The vote's ballots, by voter. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub votes: Ballots, } impl Default for Document { @@ -247,6 +258,9 @@ impl Document { image_presets: Vec::new(), annotation_categories: None, dev_snapshots: BTreeMap::new(), + timer: None, + vote: None, + votes: BTreeMap::new(), } } diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs index 4dd76bbd..5408c6c9 100644 --- a/crates/engine/src/board.rs +++ b/crates/engine/src/board.rs @@ -34,7 +34,7 @@ const STICKY_GAP: f64 = 24.0; /// A new shape's size. const SHAPE: f64 = 160.0; /// Text on board objects, and the author line under a sticky's note. -const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); +pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); /// Connectors' default colour and weight. const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); @@ -240,7 +240,7 @@ fn line_shade(c: Color) -> Color { /// Your name on what you make: the name from Preferences, else the one a live session gave you /// (none for the default name outside a session). -fn author(s: &Session) -> String { +pub(crate) fn author(s: &Session) -> String { let name = crate::live::display_name(&s.prefs); if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } } @@ -285,7 +285,7 @@ fn soft_shadow() -> Effect { } /// A text layer filling its frame's width (the frame's auto layout places it). -fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { +pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; let mut td = TextData { @@ -774,7 +774,7 @@ fn set_color(s: &mut Session, p: &Value) -> Result { n.strokes = vec![Paint::solid(line_shade(color))]; } BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], - BoardItem::Label { .. } | BoardItem::Stamp { .. } => continue, + BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue, } changed.push(id); } @@ -1472,7 +1472,7 @@ impl Session { /// label to type in). True when it was one. pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); - let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| owner_of_text(doc, te.id) == Some(whole)); + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id)); let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; self.tool.gesture = Gesture::None; self.cancel_interaction()?; @@ -1487,6 +1487,7 @@ impl Session { }; edit_text(self, label) } + BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf), b if b.has_text() => edit_text(self, whole), _ => Ok(false), } diff --git a/crates/engine/src/board_table.rs b/crates/engine/src/board_table.rs new file mode 100644 index 00000000..9cf35dcb --- /dev/null +++ b/crates/engine/src/board_table.rs @@ -0,0 +1,300 @@ +//! Board tables: a frame of row frames holding cell frames with text, all ordinary auto layout +//! layers. The table hugs its rows' height, the rows share its width and the cells share their +//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws +//! a line under itself and the column lines are thin rectangles over the table that stretch with +//! it (they are rebuilt after every edit, like the first row's header tint). + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{ + AutoLayout, BoardItem, Constraints, Document, HConstraint, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, TextAlignH, VConstraint, +}; +use figcraft_geom::Affine; +use serde_json::{Value, json}; + +use crate::board::{INK, add_text, require}; +use crate::cmd::CommandSpec; +use crate::params::{get, id, num, num_or, opt_index, req_string}; +use crate::{EngineError, Result, Session, bad, spec, textedit}; + +/// The most rows and columns a table has. +pub const MAX_ROWS: usize = 50; +pub const MAX_COLUMNS: usize = 20; +/// A new column's width, and a cell's height before its text is measured. +const CELL_W: f64 = 160.0; +const CELL_H: f64 = 40.0; +/// Room around a cell's text: [top, right, bottom, left]. +const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0]; +const TEXT_SIZE: f64 = 14.0; +const LINE_W: f64 = 1.0; +const GRID: Color = Color::from_rgb8_const(0xCF, 0xCB, 0xC4); +const HEADER: Color = Color::from_rgb8_const(0xF1, 0xEF, 0xEB); + +pub fn specs() -> Vec { + vec![ + spec!( + "board.table", + "Table", + "{rows?, columns? (default 3 × 3, or the size of `cells`; at most 50 × 20), cells? ([[text…]…] row by row), x?, y? (its top-left on the page; default centred in the view)} → {id, rows, columns, cells: [[text id…]…]}", + true, + table_cmd + ), + spec!( + "board.tableEdit", + "Edit table", + "{id? (a table or a layer in one; default the selection), action: addRow|addColumn|removeRow|removeColumn, index? (where the new one goes, or which one goes; default the last)} → {id, rows, columns, cells}", + true, + edit_cmd + ), + ] +} + +/// A count from 1 to `max` (`default` when missing). +fn count(cmd: &str, p: &Value, key: &str, default: usize, max: usize) -> Result { + match num(p, key) { + None if get(p, key).is_none() => Ok(default), + Some(v) if v.fract() == 0.0 && (1.0..=max as f64).contains(&v) => Ok(v as usize), + _ => Err(bad(cmd, format!("`{key}` is a whole number from 1 to {max}"))), + } +} + +/// A frame for the table at `index` in `parent`, made by `f`. +fn frame(doc: &mut Document, parent: NodeId, index: Option, name: &str, size: (f64, f64), f: impl FnOnce(&mut Node)) -> Result { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Frame, name, size.0, size.1); + n.fills.clear(); + n.clip = false; + f(&mut n); + doc.insert(n, parent, index)?; + Ok(id) +} + +fn make_cell(doc: &mut Document, row: NodeId, index: Option, width: f64, text: &str) -> Result { + let cell = frame(doc, row, index, "Cell", (width, CELL_H), |n| { + n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: CELL_PAD, ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Fill; + n.child_layout.sizing_v = Sizing::Hug; + n.child_layout.min_h = Some(CELL_H); + })?; + add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?; + Ok(cell) +} + +fn make_row(doc: &mut Document, table: NodeId, index: Option, width: f64, texts: &[&str]) -> Result { + let row = frame(doc, table, index, "Row", (width, CELL_H), |n| { + n.layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Fill; + n.child_layout.sizing_v = Sizing::Hug; + n.stroke.weight = LINE_W; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, LINE_W, 0.0]); + })?; + let cw = width / texts.len().max(1) as f64; + for t in texts { + make_cell(doc, row, None, cw, t)?; + } + Ok(row) +} + +/// A table of `texts` (rows of cell texts, all as long) at `at` on `parent`. +pub(crate) fn make_table(doc: &mut Document, parent: NodeId, at: (f64, f64), texts: &[Vec<&str>]) -> Result { + let columns = texts.first().map_or(1, Vec::len).max(1); + let w = CELL_W * columns as f64; + let table = frame(doc, parent, None, "Table", (w, CELL_H * texts.len() as f64), |n| { + n.transform = Affine::translate(at); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.strokes = vec![Paint::solid(GRID)]; + n.stroke.weight = LINE_W; + n.stroke.align = StrokeAlign::Inside; + n.corners.radii = [4.0; 4]; + n.clip = true; + // The outline draws over the rows' edges rather than taking room from them. + n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: [0.0; 4], strokes_included: false, ..AutoLayout::default() }); + n.child_layout.sizing_v = Sizing::Hug; + n.board = Some(BoardItem::Table); + })?; + for row in texts { + make_row(doc, table, None, w, row)?; + } + restyle(doc, table)?; + Ok(table) +} + +/// Table `table`'s rows (its laid-out children) and column lines (the others). +fn parts(doc: &Document, table: NodeId) -> (Vec, Vec) { + let kids = doc.node(table).map(|n| n.children.clone()).unwrap_or_default(); + kids.into_iter().partition(|c| doc.node(*c).is_some_and(|n| !n.child_layout.absolute)) +} + +fn cells(doc: &Document, row: NodeId) -> Vec { + doc.node(row).map(|n| n.children.clone()).unwrap_or_default() +} + +/// The first row tinted, a line under every row but the last, and the column lines over it all. +fn restyle(doc: &mut Document, table: NodeId) -> Result<()> { + let (rows, lines) = parts(doc, table); + for l in lines { + doc.remove(l)?; + } + let last = rows.len().saturating_sub(1); + for (i, r) in rows.iter().enumerate() { + let n = doc.get_mut(*r)?; + n.fills = if i == 0 { vec![Paint::solid(HEADER)] } else { Vec::new() }; + n.strokes = if i < last { vec![Paint::solid(GRID)] } else { Vec::new() }; + } + let columns = rows.first().map_or(0, |r| cells(doc, *r).len()); + let (w, h) = doc.get(table).map(|t| (t.width, t.height))?; + for i in 1..columns { + let x = w * i as f64 / columns as f64 - LINE_W / 2.0; + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Rectangle, "Column line", LINE_W, h); + n.fills = vec![Paint::solid(GRID)]; + n.transform = Affine::translate((x, 0.0)); + n.child_layout.absolute = true; + n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::TopBottom }; + n.locked = true; + doc.insert(n, table, None)?; + } + Ok(()) +} + +/// Each cell's text layer, row by row (null for a cell without one). +fn cell_texts(doc: &Document, table: NodeId) -> Value { + let text = |cell: NodeId| { + doc.node(cell).and_then(|n| n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_))))) + }; + let (rows, _) = parts(doc, table); + Value::Array(rows.iter().map(|r| Value::Array(cells(doc, *r).into_iter().map(|c| json!(text(c))).collect())).collect()) +} + +fn answer(doc: &Document, table: NodeId) -> Value { + let (rows, _) = parts(doc, table); + let columns = rows.first().map_or(0, |r| cells(doc, *r).len()); + json!({"id": table, "rows": rows.len(), "columns": columns, "cells": cell_texts(doc, table)}) +} + +fn table_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.table"; + require(s, cmd)?; + let given: Vec> = match get(p, "cells") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(rows)) => rows + .iter() + .map(|r| match r { + Value::Array(c) => c.iter().map(|t| t.as_str().map(str::to_string).ok_or_else(|| bad(cmd, "`cells` holds text"))).collect(), + _ => Err(bad(cmd, "`cells` is a list of rows, each a list of texts")), + }) + .collect::>()?, + Some(_) => return Err(bad(cmd, "`cells` is a list of rows, each a list of texts")), + }; + let widest = given.iter().map(Vec::len).max().unwrap_or(0); + let rows = count(cmd, p, "rows", if given.is_empty() { 3 } else { given.len() }, MAX_ROWS)?; + let columns = count(cmd, p, "columns", if widest == 0 { 3 } else { widest }, MAX_COLUMNS)?; + if given.len() > rows || widest > columns { + return Err(bad(cmd, format!("`cells` doesn't fit in {rows} × {columns}"))); + } + let texts: Vec> = + (0..rows).map(|r| (0..columns).map(|c| given.get(r).and_then(|row| row.get(c)).map_or("", String::as_str)).collect()).collect(); + let (w, h) = (CELL_W * columns as f64, CELL_H * rows as f64); + let view = &s.ds()?.view; + let at = (num_or(p, "x", view.cx - w / 2.0), num_or(p, "y", view.cy - h / 2.0)); + if !(at.0.is_finite() && at.1.is_finite()) { + return Err(bad(cmd, "`x` and `y` are finite numbers")); + } + let page = s.page()?; + let doc = s.doc_mut()?; + let table = make_table(doc, page, at, &texts)?; + let out = answer(doc, table); + s.set_selection(vec![table]); + Ok(out) +} + +/// The table `id` is or is in. +fn table_of(doc: &Document, id: NodeId) -> Option { + std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table))) +} + +fn edit_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.tableEdit"; + require(s, cmd)?; + let target = id(p, "id").or_else(|| s.selection().first().copied()).ok_or_else(|| EngineError::Disabled(cmd.into(), "select a table".into()))?; + let doc = s.doc()?; + doc.get(target)?; + let table = table_of(doc, target).ok_or_else(|| bad(cmd, "that isn't a table"))?; + let action = req_string(cmd, p, "action")?; + let index = opt_index(cmd, p, "index")?; + let (rows, _) = parts(doc, table); + let columns = rows.first().map_or(0, |r| cells(doc, *r).len()).max(1); + let width = doc.get(table)?.width; + let doc = s.doc_mut()?; + // `n` things: `index` (default `last`) must be at most `end`. + let at = |end: usize, last: usize| match index { + Some(i) if i > end => Err(bad(cmd, format!("`index` is from 0 to {end}"))), + Some(i) => Ok(i), + None => Ok(last), + }; + match action.as_str() { + "addRow" => { + if rows.len() >= MAX_ROWS { + return Err(bad(cmd, format!("a table has at most {MAX_ROWS} rows"))); + } + let i = at(rows.len(), rows.len())?; + // Rows come before the column lines among the table's layers. + let child = rows.get(i).or(rows.last()).and_then(|r| doc.node(table).and_then(|t| t.children.iter().position(|c| c == r))); + let child = child.map(|c| if i >= rows.len() { c + 1 } else { c }); + make_row(doc, table, child, width, &vec![""; columns])?; + } + "removeRow" => { + if rows.len() <= 1 { + return Err(bad(cmd, "a table keeps at least one row")); + } + let i = at(rows.len() - 1, rows.len() - 1)?; + if let Some(r) = rows.get(i) { + doc.remove(*r)?; + } + } + "addColumn" => { + if columns >= MAX_COLUMNS { + return Err(bad(cmd, format!("a table has at most {MAX_COLUMNS} columns"))); + } + let i = at(columns, columns)?; + let cw = width / columns as f64; + doc.get_mut(table)?.width = width + cw; + for r in &rows { + make_cell(doc, *r, Some(i), cw, "")?; + } + } + "removeColumn" => { + if columns <= 1 { + return Err(bad(cmd, "a table keeps at least one column")); + } + let i = at(columns - 1, columns - 1)?; + doc.get_mut(table)?.width = width - width / columns as f64; + for r in &rows { + if let Some(c) = cells(doc, *r).get(i) { + doc.remove(*c)?; + } + } + } + other => return Err(bad(cmd, format!("`action` is addRow, addColumn, removeRow or removeColumn, not `{other}`"))), + } + restyle(doc, table)?; + let out = answer(doc, table); + s.set_selection(vec![table]); + Ok(out) +} + +/// A double-click on a table: type in the cell under the pointer (`leaf`). False when it hit no +/// cell (a column line, the table's edge). +pub(crate) fn edit_cell(s: &mut Session, doc: &Document, leaf: NodeId) -> Result { + let in_table = |c: &NodeId| { + let row = doc.node(*c).and_then(|n| n.parent); + row.and_then(|r| doc.node(r)).and_then(|r| r.parent).and_then(|t| doc.node(t)).is_some_and(|t| t.board == Some(BoardItem::Table)) + }; + let Some(cell) = std::iter::once(leaf).chain(doc.ancestors(leaf)).find(in_table) else { return Ok(false) }; + let Some(text) = cells(doc, cell).into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) else { + return Ok(false); + }; + textedit::begin(s, text, None, false)?; + textedit::key(s, "SelectAll", false, false, false)?; + Ok(true) +} diff --git a/crates/engine/src/board_tables_tests.rs b/crates/engine/src/board_tables_tests.rs new file mode 100644 index 00000000..2c0d07bb --- /dev/null +++ b/crates/engine/src/board_tables_tests.rs @@ -0,0 +1,257 @@ +//! Boards: templates (each placed as board objects in one undo step) and tables (made from auto +//! layout frames, edited by rows and columns, typed into with the text tools, synced live). + +use figcraft_doc::{BoardItem, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::board_tests::{board, id, node}; +use crate::collab_live_tests::Room; +use crate::tools::{PointerEvent, PointerKind}; +use crate::{EngineError, Session}; + +fn count(s: &Session, f: impl Fn(&BoardItem) -> bool) -> usize { + s.doc().unwrap().nodes().filter(|n| n.board.as_ref().is_some_and(&f)).count() +} + +fn ids(v: &Value, key: &str) -> Vec { + v[key].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +/// Rows of cell texts' characters. +fn texts(s: &Session, table: &Value) -> Vec> { + let chars = |v: &Value| match &node(s, NodeId(v.as_u64().unwrap())).kind { + NodeKind::Text(td) => td.characters.clone(), + _ => panic!("not text"), + }; + table["cells"].as_array().unwrap().iter().map(|r| r.as_array().unwrap().iter().map(chars).collect()).collect() +} + +/// A table's column lines (its laid-out-over children): their x and height. +fn lines(s: &Session, table: NodeId) -> Vec<(f64, f64)> { + let doc = s.doc().unwrap(); + let t = doc.node(table).unwrap(); + t.children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| n.child_layout.absolute).map(|n| (n.transform.translation().x, n.height)).collect() +} + +#[test] +fn every_template_places_board_objects_in_one_undo_step() { + let mut s = board(); + let list = s.execute("board.templates", &json!({})).unwrap(); + let names: Vec<&str> = list.as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap()).collect(); + assert_eq!(names, ["retrospective", "brainstorm", "weeklyPlanner", "flowChart", "kanban"]); + for (i, name) in names.iter().enumerate() { + let before = s.doc().unwrap().nodes().count(); + let r = s.execute("board.template", &json!({"name": name, "x": i as f64 * 2000.0, "y": 0})).unwrap(); + let made = ids(&r, "ids"); + assert!(!made.is_empty(), "{name}"); + assert_eq!(s.selection(), made, "{name}: what it placed is selected"); + let doc = s.doc().unwrap(); + // It sits where it was asked to, about its listed size. + let b = doc.bounds_of(&made).unwrap(); + assert!((b.x0 - i as f64 * 2000.0).abs() < 1.0 && b.y0.abs() < 1.0, "{name} at {b:?}"); + let size = &list[i]; + assert!((b.width() - size["width"].as_f64().unwrap()).abs() < 2.0, "{name}: width {}", b.width()); + assert!(b.height() <= size["height"].as_f64().unwrap() + 2.0, "{name}: height {}", b.height()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().nodes().count(), before, "{name}: one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + } + // By name too, centred in the view without a place. + let view = s.ds().unwrap().view.clone(); + let r = s.execute("board.template", &json!({"name": "Kanban board"})).unwrap(); + let b = s.doc().unwrap().bounds_of(&ids(&r, "ids")).unwrap(); + assert!((b.center().x - view.cx).abs() < 2.0, "{b:?} centred on {}", view.cx); + assert!(s.execute("board.template", &json!({"name": "party"})).is_err()); + assert!(s.execute("board.template", &json!({})).is_err()); + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + assert!(matches!(d.execute("board.template", &json!({"name": "kanban"})), Err(EngineError::Disabled(..))), "boards only"); + assert!(matches!(d.execute("board.table", &json!({})), Err(EngineError::Disabled(..))), "boards only"); +} + +#[test] +fn templates_are_made_of_ordinary_board_objects() { + let mut s = board(); + s.execute("board.template", &json!({"name": "retrospective", "x": 0, "y": 0})).unwrap(); + let doc = s.doc().unwrap(); + let sections: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Section { .. })).collect(); + assert_eq!(sections.iter().map(|n| n.name.as_str()).collect::>(), ["Went well", "To improve", "Next steps"]); + for sec in §ions { + let stickies = sec.children.iter().filter(|c| matches!(doc.node(**c).unwrap().board, Some(BoardItem::Sticky { .. }))).count(); + assert_eq!(stickies, 2, "{}", sec.name); + } + // No author on a template's stickies. + assert!(doc.nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).all(|n| n.board.as_ref().unwrap().author().is_empty())); + // The flow chart's connectors join its shapes, labelled at the decision. + s.execute("board.template", &json!({"name": "flowChart", "x": 0, "y": 1000})).unwrap(); + let doc = s.doc().unwrap(); + let joined: Vec<_> = doc + .nodes() + .filter_map(|n| match &n.board { + Some(BoardItem::Connector(c)) => Some((c.from.node().unwrap(), c.to.node().unwrap())), + _ => None, + }) + .collect(); + assert_eq!(joined.len(), 6); + assert!(joined.iter().all(|(a, b)| matches!(doc.node(*a).unwrap().board, Some(BoardItem::Shape { .. })) + && matches!(doc.node(*b).unwrap().board, Some(BoardItem::Shape { .. })))); + assert_eq!(count(&s, |b| matches!(b, BoardItem::Label { .. })), 2); + // The brainstorm's topic is joined to each of its four clusters. + s.execute("board.template", &json!({"name": "brainstorm", "x": 3000, "y": 0})).unwrap(); + assert_eq!(count(&s, |b| matches!(b, BoardItem::Connector(_))), 10); +} + +#[test] +fn a_table_is_auto_layout_frames_with_a_header_and_column_lines() { + let mut s = board(); + let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [["Name", "Role"], ["Ada", "Lead"]], "rows": 3})).unwrap(); + let t = id(&r, "id"); + assert_eq!((r["rows"].as_u64(), r["columns"].as_u64()), (Some(3), Some(2))); + assert_eq!(texts(&s, &r), [["Name", "Role"], ["Ada", "Lead"], ["", ""]]); + assert_eq!(node(&s, t).board, Some(BoardItem::Table)); + assert_eq!(s.selection(), [t]); + let table = node(&s, t); + assert_eq!(table.width, 320.0); + let rows: Vec<_> = table.children.iter().map(|c| node(&s, *c)).filter(|n| !n.child_layout.absolute).collect(); + assert_eq!(rows.len(), 3); + assert!(rows[0].fills.len() == 1 && rows[1].fills.is_empty(), "the header row is tinted"); + assert!(rows[1].strokes.len() == 1 && rows[2].strokes.is_empty(), "a line under every row but the last"); + let cell = node(&s, rows[1].children[1]); + assert_eq!((cell.width, cell.transform.translation().x), (160.0, 160.0), "cells share their row"); + assert_eq!(table.height, rows.iter().map(|r| r.height).sum::(), "the table hugs its rows"); + assert_eq!(lines(&s, t), [(159.5, table.height)], "the column line runs the table's height"); + // Caps and refusals. + for bad in + [json!({"rows": 51}), json!({"columns": 21}), json!({"rows": 0}), json!({"rows": 2.5}), json!({"cells": [["a", "b", "c"]], "columns": 2})] + { + assert!(s.execute("board.table", &bad).is_err(), "{bad}"); + } + assert!(s.execute("board.table", &json!({"rows": 50, "columns": 20})).is_ok(), "the largest table"); + // It saves and reopens. + let doc = s.doc().unwrap(); + let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap(); + assert_eq!(back.node(t).unwrap().board, Some(BoardItem::Table)); +} + +#[test] +fn table_edits_add_and_remove_rows_and_columns_keeping_text() { + let mut s = board(); + let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [["a", "b"], ["c", "d"]]})).unwrap(); + let t = id(&r, "id"); + let edit = |s: &mut Session, p: Value| s.execute("board.tableEdit", &p); + let r = edit(&mut s, json!({"id": t, "action": "addRow", "index": 1})).unwrap(); + assert_eq!(texts(&s, &r), [["a", "b"], ["", ""], ["c", "d"]]); + let r = edit(&mut s, json!({"action": "addColumn", "index": 0})).unwrap(); + assert_eq!(texts(&s, &r), [["", "a", "b"], ["", "", ""], ["", "c", "d"]]); + assert_eq!(node(&s, t).width, 480.0, "a new column as wide as the others"); + assert_eq!(lines(&s, t).len(), 2); + let r = edit(&mut s, json!({"action": "removeRow", "index": 0})).unwrap(); + assert_eq!(texts(&s, &r), [["", "", ""], ["", "c", "d"]]); + let first = node(&s, node(&s, t).children[0]); + assert_eq!(first.fills.len(), 1, "the new first row is the header"); + let r = edit(&mut s, json!({"action": "removeColumn"})).unwrap(); + assert_eq!(texts(&s, &r), [["", ""], ["", "c"]]); + assert_eq!(node(&s, t).width, 320.0); + // From a cell inside it too. + let cell_text = NodeId(r["cells"][1][1].as_u64().unwrap()); + let r = edit(&mut s, json!({"id": cell_text, "action": "addRow"})).unwrap(); + assert_eq!(r["rows"], 3); + // Undo, one step each. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, t).width, 480.0); + // Refusals: the wrong action or index, the last row or column, beyond the caps, not a table. + for p in [ + json!({"id": t, "action": "explode"}), + json!({"id": t, "action": "addRow", "index": 9}), + json!({"id": t, "action": "removeColumn", "index": 3}), + json!({"id": t, "action": "addRow", "index": -1}), + json!({"id": t}), + ] { + assert!(edit(&mut s, p.clone()).is_err(), "{p}"); + } + let one = id(&s.execute("board.table", &json!({"rows": 1, "columns": 1})).unwrap(), "id"); + assert!(edit(&mut s, json!({"id": one, "action": "removeRow"})).is_err()); + assert!(edit(&mut s, json!({"id": one, "action": "removeColumn"})).is_err()); + let full = id(&s.execute("board.table", &json!({"rows": 50, "columns": 20})).unwrap(), "id"); + assert!(edit(&mut s, json!({"id": full, "action": "addRow"})).is_err()); + assert!(edit(&mut s, json!({"id": full, "action": "addColumn"})).is_err()); + let sticky = id(&s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(), "id"); + assert!(edit(&mut s, json!({"id": sticky, "action": "addRow"})).is_err()); +} + +#[test] +fn a_double_click_types_in_the_cell_and_its_row_grows_with_the_text() { + let mut s = board(); + let r = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap(); + let t = id(&r, "id"); + let h0 = node(&s, t).height; + s.execute("select.none", &json!({})).unwrap(); + // The middle cell (columns 160 wide, rows about 40 tall). + let row_h = h0 / 3.0; + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 240.0, row_h * 1.5, 1.0)).unwrap(); + let te = s.ds().unwrap().text_edit.clone().expect("typing in the cell"); + assert_eq!(te.id.0, r["cells"][1][1].as_u64().unwrap()); + crate::textedit::insert( + &mut s, + "A longer note +over +several lines", + ) + .unwrap(); + crate::textedit::end(&mut s).unwrap(); + let h1 = node(&s, t).height; + assert!(h1 > h0 + 10.0, "the row grew: {h0} → {h1}"); + assert!(lines(&s, t).iter().all(|(_, h)| (*h - h1).abs() < 1e-6), "column lines follow: {:?} vs {h1}", lines(&s, t)); + // A plain click picks the table whole. + s.execute("select.none", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 240.0, 10.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 240.0, 10.0, 1.0)).unwrap(); + assert_eq!(s.selection(), [t]); +} + +#[test] +fn tables_and_templates_sync_in_live_sessions() { + let s = board(); + let mut room = Room::new(s.doc().unwrap().clone(), 2, 11); + let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id"); + room.s(1).execute("board.template", &json!({"name": "flowChart", "x": 0, "y": 600})).unwrap(); + room.settle(); + room.check(); + room.s(1).execute("board.tableEdit", &json!({"id": t, "action": "addColumn"})).unwrap(); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(t).unwrap().width, 640.0); + assert_eq!(doc.nodes().filter(|n| matches!(n.board, Some(BoardItem::Connector(_)))).count(), 6); +} + +#[test] +fn template_and_table_commands_survive_junk() { + let junk = [ + json!({}), + json!(null), + json!([1]), + json!({"name": 7, "x": "NaN", "y": 1e308, "rows": "x", "columns": -1, "cells": 5}), + json!({"name": "kanban", "x": -1e308, "y": 1e308, "rows": 1e308, "columns": 0.5, "cells": [[1, 2]]}), + json!({"name": "\u{0}", "cells": [["a"], "b"], "rows": 50, "columns": 20}), + json!({"id": "x", "action": 5, "index": 1e308}), + json!({"id": -1, "action": "addRow", "index": -1}), + json!({"id": 99_999, "action": "removeColumn", "index": "NaN"}), + json!({"id": 2, "action": "addColumn", "index": 0}), + ]; + for cmd in ["board.template", "board.templates", "board.table", "board.tableEdit"] { + for j in &junk { + let mut s = board(); + let t = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let _ = s.execute("board.template", &json!({"name": "brainstorm"})); + let mut j = j.clone(); + if j.get("action").is_some_and(|a| a == "addColumn") { + j["id"] = t; + } + if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} diff --git a/crates/engine/src/board_templates.rs b/crates/engine/src/board_templates.rs new file mode 100644 index 00000000..2d86ac0b --- /dev/null +++ b/crates/engine/src/board_templates.rs @@ -0,0 +1,261 @@ +//! Board templates: ready-made layouts (a retrospective, a brainstorm, a weekly planner, a flow +//! chart, a kanban board) placed on a board as ordinary board objects in one undo step. They are +//! built in code by running the board commands, so everything in them edits like anything else on +//! the board; their wording is FigCraft's own. + +use std::sync::Arc; + +use figcraft_doc::{Document, NodeId}; +use serde_json::{Value, json}; + +use crate::board::require; +use crate::cmd::CommandSpec; +use crate::params::{id_of, num, string}; +use crate::{EngineError, Result, Session, bad, spec}; + +/// A board template. +pub struct BoardTemplate { + pub id: &'static str, + pub name: &'static str, + /// One line about what is in it. + pub description: &'static str, + /// Its width and height on the board. + pub size: (f64, f64), + make: fn(&mut Builder) -> Result<()>, +} + +pub static TEMPLATES: &[BoardTemplate] = &[ + BoardTemplate { + id: "retrospective", + name: "Retrospective", + description: "Three sections to look back on a stretch of work: what went well, what to improve, what to do next", + size: (1280.0, 592.0), + make: retrospective, + }, + BoardTemplate { + id: "brainstorm", + name: "Brainstorm", + description: "A question in the middle and four clusters of ideas around it", + size: (1344.0, 760.0), + make: brainstorm, + }, + BoardTemplate { + id: "weeklyPlanner", + name: "Weekly planner", + description: "A column for each working day with a note to start from", + size: (1380.0, 632.0), + make: weekly_planner, + }, + BoardTemplate { + id: "flowChart", + name: "Flow chart", + description: "Steps, a decision and its two outcomes joined by connectors", + size: (1220.0, 360.0), + make: flow_chart, + }, + BoardTemplate { + id: "kanban", + name: "Kanban board", + description: "To do, Doing and Done columns with cards to move along", + size: (900.0, 812.0), + make: kanban, + }, +]; + +pub fn specs() -> Vec { + vec![ + spec!( + "board.template", + "Board template", + "{name (a template id from board.templates), x?, y? (its top-left on the page; default centred in the view)} → {name, ids} its sections, stickies, shapes, connectors and text, selected", + true, + template_cmd + ), + spec!("board.templates", "List board templates", "{} → [{id, name, description, width, height}]", false, |_, _| { + Ok(Value::Array( + TEMPLATES + .iter() + .map(|t| json!({"id": t.id, "name": t.name, "description": t.description, "width": t.size.0, "height": t.size.1})) + .collect(), + )) + }), + ] +} + +/// The template called `name` (its id, or its name in any case). +pub fn find(name: &str) -> Option<&'static BoardTemplate> { + let name = name.trim(); + TEMPLATES.iter().find(|t| t.id.eq_ignore_ascii_case(name) || t.name.eq_ignore_ascii_case(name)) +} + +fn template_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.template"; + require(s, cmd)?; + let names = || TEMPLATES.iter().map(|t| t.id).collect::>().join(", "); + let name = string(p, "name").ok_or_else(|| bad(cmd, format!("`name` is one of {}", names())))?; + let t = find(&name).ok_or_else(|| bad(cmd, format!("no template `{name}`: one of {}", names())))?; + let view = &s.ds()?.view; + let at = (num(p, "x").unwrap_or(view.cx - t.size.0 / 2.0), num(p, "y").unwrap_or(view.cy - t.size.1 / 2.0)); + if !(at.0.is_finite() && at.1.is_finite()) { + return Err(bad(cmd, "`x` and `y` are finite numbers")); + } + let page = s.page()?; + let mut b = Builder { s, page, at, made: Vec::new() }; + (t.make)(&mut b)?; + let made = b.made; + s.set_selection(made.clone()); + Ok(json!({"name": t.id, "ids": made})) +} + +/// A board with template `id` at the page's origin and nothing else (the picker's previews). +pub fn preview(id: &str) -> Result { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({"name": id}))?; + s.execute("board.template", &json!({"name": id, "x": 0, "y": 0}))?; + let ds = s.docs.pop().ok_or(EngineError::NoDocument)?; + Ok(Arc::try_unwrap(ds.doc).unwrap_or_else(|d| (*d).clone())) +} + +/// Places a template's objects: positions are from its top-left (`at` on the page), or inside the +/// section they go in. +struct Builder<'a> { + s: &'a mut Session, + page: NodeId, + at: (f64, f64), + /// What it placed on the page. + made: Vec, +} + +impl Builder<'_> { + /// Run board command `cmd` (in this command's undo step); the layer it made. + fn run(&mut self, cmd: &str, p: Value) -> Result { + let spec = crate::cmd::find_command(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?; + let v = (spec.run)(self.s, &p)?; + v.get("id").and_then(id_of).ok_or_else(|| EngineError::Other(format!("`{cmd}` made no layer"))) + } + + /// A layer on the page at (`x`, `y`) from the template's corner. + fn top(&mut self, cmd: &str, x: f64, y: f64, p: Value) -> Result { + let id = self.run(cmd, placed(p, self.page, (self.at.0 + x, self.at.1 + y)))?; + self.made.push(id); + Ok(id) + } + + fn title(&mut self, text: &str) -> Result { + self.top("node.create", 0.0, 0.0, json!({"type": "text", "text": text, "props": {"fontSize": 32, "fills": ["#1E1E1E"]}})) + } + + fn section(&mut self, name: &str, (x, y, w, h): (f64, f64, f64, f64), fill: &str) -> Result { + self.top("node.create", x, y, json!({"type": "section", "name": name, "width": w, "height": h, "props": {"fills": [fill]}})) + } + + /// A sticky in `section` (or on the page) at (`x`, `y`) in it. + fn sticky(&mut self, section: Option, (x, y): (f64, f64), text: &str, color: &str) -> Result { + let p = json!({"text": text, "color": color, "author": false}); + match section { + Some(sec) => self.run("board.sticky", placed(p, sec, (x, y))), + None => self.top("board.sticky", x, y, p), + } + } + + fn shape(&mut self, shape: &str, (x, y, w, h): (f64, f64, f64, f64), text: &str, color: &str) -> Result { + self.top("board.shape", x, y, json!({"shape": shape, "width": w, "height": h, "text": text, "color": color})) + } + + fn connector(&mut self, from: NodeId, to: NodeId, extra: Value) -> Result { + let id = self.run("board.connector", with(extra, [("from", json!(from)), ("to", json!(to))]))?; + self.made.push(id); + Ok(id) + } +} + +/// Params `p` with `entries` added (`p` an object). +fn with(mut p: Value, entries: [(&str, Value); N]) -> Value { + if let Some(o) = p.as_object_mut() { + o.extend(entries.map(|(k, v)| (k.to_string(), v))); + } + p +} + +/// Params `p` placing a layer in `parent` at `at`. +fn placed(p: Value, parent: NodeId, at: (f64, f64)) -> Value { + with(p, [("parent", json!(parent)), ("x", json!(at.0)), ("y", json!(at.1))]) +} + +fn retrospective(b: &mut Builder) -> Result<()> { + b.title("Retrospective")?; + let parts = [ + ("Went well", "#EAF6E1", "green", ["Something that helped us", "A win worth repeating"]), + ("To improve", "#FCE8EF", "pink", ["Something that slowed us down", "A question we still have"]), + ("Next steps", "#E6F0FC", "blue", ["One change to try next", "Who looks after it?"]), + ]; + for (i, (name, fill, color, notes)) in parts.into_iter().enumerate() { + let sec = b.section(name, (i as f64 * 440.0, 72.0, 400.0, 520.0), fill)?; + for (j, note) in notes.into_iter().enumerate() { + b.sticky(Some(sec), (100.0, 60.0 + j as f64 * 230.0), note, color)?; + } + } + Ok(()) +} + +fn brainstorm(b: &mut Builder) -> Result<()> { + let topic = b.shape("ellipse", (532.0, 300.0, 280.0, 160.0), "What are we trying to solve?", "violet")?; + let clusters = [((0.0, 0.0), "yellow", 1), ((920.0, 0.0), "orange", 0), ((0.0, 560.0), "green", 1), ((920.0, 560.0), "blue", 0)]; + for ((x, y), color, near) in clusters { + let notes = [b.sticky(None, (x, y), "An idea", color)?, b.sticky(None, (x + 224.0, y), "Build on it", color)?]; + if let Some(n) = notes.get(near) { + b.connector(topic, *n, json!({"style": "straight", "endCap": "none"}))?; + } + } + Ok(()) +} + +fn weekly_planner(b: &mut Builder) -> Result<()> { + b.title("This week")?; + let days = [ + ("Monday", "Plan the week"), + ("Tuesday", "Focus for the day"), + ("Wednesday", "Focus for the day"), + ("Thursday", "Focus for the day"), + ("Friday", "Wrap up and share"), + ]; + for (i, (day, note)) in days.into_iter().enumerate() { + let sec = b.section(day, (i as f64 * 280.0, 72.0, 260.0, 560.0), "#FFFFFF")?; + b.sticky(Some(sec), (30.0, 60.0), note, if i % 2 == 0 { "yellow" } else { "orange" })?; + } + Ok(()) +} + +fn flow_chart(b: &mut Builder) -> Result<()> { + let start = b.shape("ellipse", (0.0, 30.0, 160.0, 100.0), "Start", "green")?; + let step = b.shape("rectangle", (240.0, 20.0, 180.0, 120.0), "Do the first step", "blue")?; + let check = b.shape("diamond", (500.0, 0.0, 200.0, 160.0), "Did it work?", "yellow")?; + let carry = b.shape("roundedRectangle", (800.0, 20.0, 180.0, 120.0), "Carry on", "blue")?; + let done = b.shape("ellipse", (1060.0, 30.0, 160.0, 100.0), "Done", "green")?; + let other = b.shape("roundedRectangle", (510.0, 240.0, 180.0, 120.0), "Try another way", "orange")?; + let elbow = json!({"style": "elbow"}); + b.connector(start, step, elbow.clone())?; + b.connector(step, check, elbow.clone())?; + b.connector(check, carry, json!({"style": "elbow", "label": "Yes"}))?; + b.connector(carry, done, elbow)?; + b.connector(check, other, json!({"style": "elbow", "fromSide": "bottom", "toSide": "top", "label": "No"}))?; + // Back round to the step: out of the side, into the step from below. + b.connector(other, step, json!({"style": "elbow", "fromSide": "left", "toSide": "bottom"}))?; + Ok(()) +} + +fn kanban(b: &mut Builder) -> Result<()> { + b.title("Team board")?; + let columns: [(&str, &str, &[&str]); 3] = [ + ("To do", "yellow", &["Write the brief", "Sketch the first screens", "Plan a test session"]), + ("Doing", "blue", &["Review the copy"]), + ("Done", "green", &["Set up the board"]), + ]; + for (i, (name, color, cards)) in columns.into_iter().enumerate() { + let sec = b.section(name, (i as f64 * 310.0, 72.0, 280.0, 740.0), "#FFFFFF")?; + for (j, card) in cards.iter().enumerate() { + b.sticky(Some(sec), (40.0, 60.0 + j as f64 * 220.0), card, color)?; + } + } + Ok(()) +} diff --git a/crates/engine/src/branch_live_tests.rs b/crates/engine/src/branch_live_tests.rs new file mode 100644 index 00000000..ae1a5c87 --- /dev/null +++ b/crates/engine/src/branch_live_tests.rs @@ -0,0 +1,240 @@ +//! Branches of a file in a live session: the branch stays with its author and makes ids in their +//! own client space; merging is one synced edit (and one step of the author's undo history) that +//! everyone receives; peers' edits on main while the branch is open merge against the branch's own +//! base, and conflicts with them resolve as offline; update from main takes them in; a view link +//! still refuses merging; a branch made outside the session that adds layers is refused instead +//! of being sent for the server to refuse. Plus a fuzz test with random real commands. + +use figcraft_collab::sim::Rng; +use figcraft_doc::ids::space_of; +use figcraft_doc::{Document, NodeId}; +use serde_json::{Map, Value, json}; + +use crate::Session; +use crate::branch_tests::{base_with_lists, random_edit}; +use crate::collab_live_tests::{Room, frame_and_rect}; +use crate::collab_tests::base; +use crate::live::{Access, Event, Role, VIEW_ONLY}; +use crate::live_tests::{info, push, relay, serve_as, session}; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn me(s: &Session) -> u32 { + s.live().client().unwrap().id() +} + +fn version_names(s: &mut Session) -> Vec { + ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect() +} + +/// The conflict keys of branch `id`'s review. +fn conflict_keys(s: &mut Session, id: &str) -> Vec { + let review = ok(s, "branch.review", json!({"id": id})); + review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn a_branch_merges_into_the_session_as_one_synced_edit() { + let mut room = Room::new(base(), 2, 7); + let (f, r) = frame_and_rect(room.server.document()); + let you = me(room.s(0)); + let id = ok(room.s(0), "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string(); + // On the branch tab: a recolour, a new layer and a new style. + ok(room.s(0), "fill.set", json!({"ids": [r], "color": "#FF0000"})); + let made = NodeId(ok(room.s(0), "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + assert_eq!(space_of(made.0), you, "the branch makes ids in your own client space"); + ok(room.s(0), "style.create", json!({"type": "paint", "name": "Brand", "from": r})); + room.settle(); + assert!(!room.server.document().contains(made), "the branch stays with its author"); + // Someone else edits the main file meanwhile. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Renamed by a peer"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + assert_eq!(ok(room.s(0), "branch.merge", json!({"id": id}))["merged"], true); + room.settle(); + room.check(); + let doc = room.server.document(); + assert!(doc.contains(made), "everyone has the merge"); + assert_eq!(doc.node(f).unwrap().name, "Renamed by a peer", "the peer's edit stays"); + assert!(doc.styles.iter().any(|st| st.name == "Brand")); + assert_ne!(doc.node(r).unwrap().fills, base().node(r).unwrap().fills); + // The merge is one step of the author's own undo history. + ok(room.s(0), "edit.undo", json!({})); + room.settle(); + room.check(); + assert!(!room.server.document().contains(made)); + assert_eq!(room.server.document().node(f).unwrap().name, "Renamed by a peer"); +} + +#[test] +fn conflicts_with_peers_resolve_as_offline() { + let mut room = Room::new(base(), 2, 3); + let (f, r) = frame_and_rect(room.server.document()); + let r2 = room.server.document().node(f).unwrap().children[1]; + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Kept on the branch"}})); + ok(room.s(0), "node.set", json!({"id": r2, "props": {"opacity": 0.5}})); + // A peer deletes the layer the branch edited and sets the same property another way. + ok(room.s(1), "edit.delete", json!({"ids": [r]})); + ok(room.s(1), "node.set", json!({"id": r2, "props": {"opacity": 0.2}})); + room.settle(); + let keys = conflict_keys(room.s(0), &id); + let mut want = vec![format!("{r}/layer"), format!("{r2}/opacity")]; + want.sort(); + let mut got = keys.clone(); + got.sort(); + assert_eq!(got, want); + assert!(room.s(0).execute("branch.merge", &json!({"id": id})).is_err(), "conflicts first"); + // The branch's edits win the deletion, main wins the opacity. + let picks: Map = keys.iter().map(|k| (k.clone(), json!(if k.ends_with("/layer") { "branch" } else { "main" }))).collect(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(r).map(|n| n.name.as_str()), Some("Kept on the branch"), "the deleted layer is back, with the branch's edit"); + assert_eq!(doc.node(r2).unwrap().opacity, 0.2); +} + +#[test] +fn update_from_main_takes_in_peers_edits_and_shows_in_the_branch_history() { + let mut room = Room::new(base(), 2, 5); + let (f, r) = frame_and_rect(room.server.document()); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Branch"}})); + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 1"}})); + room.settle(); + assert_eq!(ok(room.s(0), "branch.updateFromMain", json!({}))["updated"], true); + assert_eq!(room.s(0).doc().unwrap().node(f).unwrap().name, "Peer 1", "the branch tab has the peer's edit"); + assert_eq!(version_names(room.s(0)), ["Branch created", "Updated from main"]); + // The new base is main as it was then: a later peer edit is main's change, not a conflict. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 2"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + ok(room.s(0), "branch.merge", json!({})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!((doc.node(f).unwrap().name.as_str(), doc.node(r).unwrap().name.as_str()), ("Peer 2", "Branch")); +} + +#[test] +fn a_view_link_still_refuses_merging() { + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + let mut doc = Document::new("Shared"); + let page = doc.first_page().unwrap(); + let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let (_server, id, welcome) = serve_as(&doc, Access::View); + let mut you = info(Role::Guest, id); + you.access = Access::View; + push(&script, vec![Event::Welcome { info: you, peers: vec![] }, welcome]); + s.live_poll(); + assert_eq!(s.active_read_only(), Some(VIEW_ONLY)); + // The branch is yours to edit; merging it would edit the file you may only view. + ok(&mut s, "branch.create", json!({})); + ok(&mut s, "node.set", json!({"id": card, "props": {"name": "Mine"}})); + ok(&mut s, "branch.updateFromMain", json!({})); + match s.execute("branch.merge", &json!({})) { + Err(crate::EngineError::Disabled(_, why)) => assert_eq!(why, VIEW_ONLY), + other => panic!("expected a refusal, got {other:?}"), + } + assert!(script.lock().unwrap().msgs.is_empty(), "nothing was sent"); +} + +#[test] +fn a_branch_made_outside_the_session_merges_edits_but_not_new_layers() { + let (mut s, script) = session(); + let r = NodeId(ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let edits = ok(&mut s, "branch.create", json!({"name": "Edits"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Edited before the session"}})); + ok(&mut s, "doc.switch", json!({"index": 0})); + let adds = ok(&mut s, "branch.create", json!({"name": "Adds"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "shape.ellipse", json!({"x": 90, "y": 0, "width": 20, "height": 20})); + ok(&mut s, "doc.switch", json!({"index": 0})); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, id, welcome) = serve_as(s.doc().unwrap(), Access::Edit); + push(&script, vec![welcome]); + s.live_poll(); + assert!(s.live().syncing()); + // Its new layer is in another id space: refused here rather than by the server. + match s.execute("branch.merge", &json!({"id": adds})) { + Err(crate::EngineError::Disabled(_, why)) => assert!(why.contains("outside this live session"), "{why}"), + other => panic!("expected a refusal, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node_count(), server.document().node_count(), "nothing merged"); + // Edits of layers everyone has go through. + ok(&mut s, "branch.merge", json!({"id": edits})); + let answers = relay(&script, &mut server, id); + push(&script, answers); + s.live_poll(); + assert_eq!(server.document().node(r).unwrap().name, "Edited before the session"); + assert!(*s.doc().unwrap() == *server.document()); +} + +/// The live fuzz test: three people edit with random real commands (export settings and guides +/// included) while one of them works on a branch; every conflict resolved for a random side, the +/// merge goes through the server as it is and everyone converges on it. +#[test] +fn random_edits_on_a_branch_merge_into_a_live_session() { + let (mut created, mut conflicted) = (0, 0); + for seed in 0..8 { + let mut room = Room::new(base_with_lists(), 3, seed); + let mut rng = Rng::new(seed + 50); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + for _ in 0..30 { + let i = rng.below(3); + let (cmd, params) = random_edit(room.s(i).doc().unwrap(), &mut rng, true); + let _ = room.s(i).execute(cmd, ¶ms); + if rng.chance(0.3) { + room.settle(); + } + } + room.settle(); + let keys = conflict_keys(room.s(0), &id); + conflicted += usize::from(!keys.is_empty()); + let picks: Map = keys.into_iter().map(|k| (k, json!(if rng.chance(0.5) { "branch" } else { "main" }))).collect(); + let before = room.server.document().clone(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + let merged = room.s(0).doc().unwrap().clone(); + assert!(merged.validate().is_empty(), "seed {seed}: {:?}", merged.validate()); + created += usize::from(merged.ids().any(|n| !before.contains(n))); + room.settle(); + room.check(); + assert!(*room.server.document() == merged, "seed {seed}: the server took the merge as it is"); + } + assert!(created >= 3 && conflicted >= 2, "{created} merges added layers, {conflicted} had conflicts"); +} + +/// No branch command panics on junk parameters in a live session, from the main file's tab or +/// the branch's, and the session stays in sync. +#[test] +fn branch_commands_survive_junk_params_in_a_live_session() { + let junk = [ + json!({}), + json!(null), + json!([1, 2]), + json!({"id": 5, "name": 1e308, "resolutions": [1]}), + json!({"id": "nope", "resolutions": {"1/layer": "both", "doc/name": 7}}), + json!({"name": "\u{0}".repeat(500), "resolutions": {"x": "branch"}}), + ]; + let mut room = Room::new(base(), 2, 11); + ok(room.s(0), "branch.create", json!({})); + let ids: Vec<&str> = crate::branches::specs().iter().map(|s| s.id).collect(); + for tab in [0, 1] { + for id in &ids { + for j in &junk { + let s = room.s(0); + if s.docs.len() > tab { + let _ = s.execute("doc.switch", &json!({"index": tab})); + } + if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + room.settle(); + } + } + } + room.check(); +} diff --git a/crates/engine/src/branch_tests.rs b/crates/engine/src/branch_tests.rs index 1c2cc769..f0e8a72e 100644 --- a/crates/engine/src/branch_tests.rs +++ b/crates/engine/src/branch_tests.rs @@ -1,9 +1,9 @@ //! Branches through commands: create, edit on both sides, review, resolve, merge as one undo step, //! update from main, archive, saving with the file, and a fuzz test over random real commands. -use figcraft_collab::merge::{Side, plan}; +use figcraft_collab::merge::{Key, Side, plan}; use figcraft_collab::sim::Rng; -use figcraft_collab::{Change, DocProp, Mode, Replica, Target}; +use figcraft_collab::{Change, DocProp, Mode, Prop, Replica, Target}; use figcraft_doc::{Document, NodeId, NodeKind}; use serde_json::{Value, json}; @@ -215,26 +215,68 @@ fn shows(changes: &[Change], doc: &Document) -> Result<(), String> { Ok(()) } -/// The fuzz test: random real commands on a branch and on main. Without conflicts the merge (both -/// ways) equals applying both change sets and shows every change of either side; with conflicts, -/// resolving them all for either side gives a valid document. +/// [`base`] with two export settings and two guides on its first frame (lists that merge item by +/// item). +pub(crate) fn base_with_lists() -> Document { + let mut doc = base(); + let f = doc.node(doc.first_page().unwrap()).unwrap().children[0]; + let n = doc.get_mut(f).unwrap(); + n.exports = ["@1", "@2"].map(|suffix| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }).to_vec(); + n.guides = vec![figcraft_doc::Guide { vertical: true, offset: 10.0 }, figcraft_doc::Guide { vertical: false, offset: 20.0 }]; + doc +} + +/// A random real command: often an edit of a frame's export settings or guides, else +/// [`random_command`]. +pub(crate) fn random_edit(doc: &Document, rng: &mut Rng, with_undo: bool) -> (&'static str, Value) { + if rng.chance(0.6) { + return random_command(doc, rng, with_undo); + } + let frames: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Frame)).map(|n| n.id).collect(); + let f = rng.pick(&frames).map_or(json!(0), |i| json!(i)); + let i = rng.below(3); + match rng.below(6) { + 0 => ("export.add", json!({"ids": [f]})), + 1 => ("export.update", json!({"ids": [f], "index": i, "suffix": format!("@{}", rng.below(9))})), + 2 => ("export.remove", json!({"ids": [f], "index": i})), + 3 => ("guide.add", json!({"frame": f, "vertical": rng.chance(0.5), "offset": rng.below(300)})), + 4 => ("guide.move", json!({"frame": f, "index": i, "offset": rng.below(300)})), + _ => ("guide.remove", json!({"frame": f, "index": i})), + } +} + +/// The fuzz test: random real commands on a branch and on main (export settings and guides +/// included). Without conflicts the merge (both ways) equals applying both change sets and shows +/// every change of either side; with conflicts, resolving them all for either side gives a valid +/// document. #[test] fn random_edits_on_both_sides_merge() { - let (mut disjoint, mut conflicted) = (0, 0); + let (mut disjoint, mut conflicted, mut lists) = (0, 0, 0); for seed in 0..60 { let mut rng = Rng::new(seed); - let start = base(); + let start = base_with_lists(); let mut on_branch = start.clone(); on_branch.set_client_id(start.highest_id_space() + 1); let mut sides = [Session::with_document(on_branch), Session::with_document(start.clone())]; for s in &mut sides { for _ in 0..1 + rng.below(5) { - let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false); + let (cmd, params) = random_edit(s.doc().unwrap(), &mut rng, false); let _ = s.execute(cmd, ¶ms); } } let [b, m] = sides.map(|s| s.doc().unwrap().clone()); let p = plan(&start, &b, &m).unwrap(); + // Export settings or guides of a layer changed on both sides that merged item by item. + let list_writes = |changes: &[Change]| -> std::collections::BTreeSet { + changes + .iter() + .filter_map(|c| match c { + Change::Set { id, prop: prop @ (Prop::Exports | Prop::Guides), .. } => Some(Key::Node(*id, *prop)), + _ => None, + }) + .collect() + }; + lists += list_writes(&p.branch).intersection(&list_writes(&p.main)).filter(|k| !p.conflicts.iter().any(|c| c.key == **k)).count(); if p.conflicts.is_empty() { disjoint += 1; let none = |_: &figcraft_collab::merge::Key| None; @@ -260,5 +302,5 @@ fn random_edits_on_both_sides_merge() { } } } - assert!(disjoint >= 10 && conflicted >= 5, "{disjoint} disjoint, {conflicted} with conflicts"); + assert!(disjoint >= 10 && conflicted >= 5 && lists >= 3, "{disjoint} disjoint, {conflicted} with conflicts, {lists} lists merged item by item"); } diff --git a/crates/engine/src/branch_versions_tests.rs b/crates/engine/src/branch_versions_tests.rs index 30bdf736..30f5134e 100644 --- a/crates/engine/src/branch_versions_tests.rs +++ b/crates/engine/src/branch_versions_tests.rs @@ -35,12 +35,12 @@ fn branched() -> (Session, String, NodeId) { fn versions_saved_on_a_branch_are_the_branchs_own() { let (mut s, _, r) = branched(); let original = layer_name(&s, r); - // A new branch's history starts empty: the main file's versions stay with it. - assert!(names(&mut s).is_empty()); + // A new branch's history starts with its creation: the main file's versions stay with it. + assert_eq!(names(&mut s), ["Branch created"]); ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Before"}})); let v = ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}))["id"].clone(); ok(&mut s, "node.set", json!({"id": r, "props": {"name": "After"}})); - assert_eq!(names(&mut s), ["Branch v1"]); + assert_eq!(names(&mut s), ["Branch created", "Branch v1"]); // Viewing a branch version shows it read-only; going back shows the branch as it is. ok(&mut s, "version.preview", json!({"id": v})); assert_eq!(layer_name(&s, r), "Before"); @@ -77,13 +77,13 @@ fn branch_versions_save_with_the_file_and_survive_merging() { ok(&mut t, "file.open", json!({"path": path})); assert_eq!(names(&mut t), ["Main v1"]); ok(&mut t, "branch.open", json!({"id": id})); - assert_eq!(names(&mut t), ["Branch v1", "Autosave"]); + assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]); // Merging adds a version to the main file's history; the branch keeps its own. ok(&mut t, "branch.merge", json!({})); assert_eq!(names(&mut t), ["Main v1", "Autosave", "Merged “Explore”"]); assert_eq!(layer_name(&t, r), "On the branch, later"); ok(&mut t, "branch.open", json!({"id": id})); - assert_eq!(names(&mut t), ["Branch v1", "Autosave"]); + assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]); std::fs::remove_dir_all(&dir).ok(); } @@ -94,7 +94,7 @@ fn archiving_keeps_the_branch_history() { ok(&mut s, "branch.archive", json!({})); assert_eq!(names(&mut s), ["Main v1"]); ok(&mut s, "branch.open", json!({"id": id})); - assert_eq!(names(&mut s), ["Branch v1"]); + assert_eq!(names(&mut s), ["Branch created", "Branch v1"]); } #[test] diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 8bff0568..a4589180 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -4,6 +4,13 @@ //! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a //! branch tab's edits and its own version history go into its branch ([`sync`]) and are saved with //! the main file. Merging adds a version to the main file's history; the branch keeps its own. +//! +//! In a live session a branch stays with the person who made it (others don't see it), and +//! merging it is one edit of the session document, which syncs to everyone like any other. The +//! session server takes new layers only in the sender's own id space, so a branch made in the +//! session makes its ids in yours, far above the ids the main file makes there ([`WINDOW`]). +//! Merging a branch made outside the session is refused when it adds layers, styles, variables or +//! comments (their ids are in another space). use std::collections::BTreeMap; use std::sync::Arc; @@ -11,6 +18,7 @@ use std::sync::Arc; pub use figcraft_collab::merge::Side; use figcraft_collab::merge::{self, Conflict, Key, Plan}; use figcraft_collab::{Change, Entry, Prop, Target}; +use figcraft_doc::ids::space_of; use figcraft_doc::{Document, NodeId}; use figcraft_format::Branch; pub use figcraft_format::BranchState; @@ -27,7 +35,7 @@ pub fn specs() -> Vec { spec!( "branch.create", "Create branch", - "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base) → {id, name, index (its tab)}", + "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base); in a live session it stays yours → {id, name, index (its tab)}", false, create ), @@ -53,14 +61,14 @@ pub fn specs() -> Vec { spec!( "branch.updateFromMain", "Update from main", - "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch; refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch (a version in its history); refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", false, |s, p| combine(s, p, Side::Main) ), spec!( "branch.merge", "Merge branch", - "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step, then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step (in a live session one edit everyone gets; refused on a view link), then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", false, |s, p| combine(s, p, Side::Branch) ), @@ -219,11 +227,42 @@ fn disabled(cmd: &str, why: impl Into) -> EngineError { EngineError::Disabled(cmd.into(), why.into()) } -/// shortcut: branches of a file in a live session are refused (its participants' client ids -/// aren't known here, so a branch's id space could clash); allow them once the server hands out -/// branch id spaces. -fn refuse_live(s: &Session, cmd: &str, uid: u64) -> Result<()> { - if s.live_doc(uid) { Err(disabled(cmd, "branches aren't available in a live session")) } else { Ok(()) } +/// shortcut: in a live session a branch makes ids in your own client space starting this far +/// above every id made there on the main file or its other branches, so neither side makes the +/// other's ids before the merge unless one makes 16 million first; have the server hand out +/// branch id spaces if that ever matters. +const WINDOW: u64 = 1 << 24; + +/// Where a new branch of the file in tab `main` starts making ids in your live session's client +/// space `client`: [`WINDOW`] above the main file's and every branch's next id there (`None`: the +/// space is full). +fn live_window(s: &Session, main: u64, client: u32) -> Option { + let m = s.docs.iter().find(|d| d.uid == main)?; + let tabs = s.docs.iter().filter(|d| d.branch_of.as_ref().is_some_and(|(t, _)| *t == main)).map(|d| d.doc.next_id()); + let next = m.branches.iter().map(|b| b.head.next_id()).chain(tabs).chain([m.doc.next_id()]); + let top = next.filter(|n| space_of(n.0) == client).max()?; + let start = top.0.checked_add(WINDOW)?; + (space_of(start) == client).then_some(NodeId(start)) +} + +/// What merging into a live session's document would add that its server won't take from client +/// `client`: a new layer outside your id space (unless it is a layer of a base taken in this +/// session, `in_session`, that main deleted since: the server takes those back), or a new style, +/// variable, collection or comment in another client's space. `None`: everything goes through. +fn foreign(plan: &Plan, base: &Document, main: &Document, merged: &Document, client: u32, in_session: bool) -> Option { + let outside = |n: NodeId| space_of(n.0) != client && !(in_session && base.contains(n)); + if let Some(n) = merged.nodes().find(|n| !main.contains(n.id) && outside(n.id)) { + return Some(format!("layer “{}”", n.name)); + } + plan.changes(Side::Branch).iter().find_map(|c| match c { + Change::Put { entry, .. } + if entry.id_space().is_some_and(|sp| sp != 0 && sp != client) + && plan.base().value(&Target::Entry(entry.clone())).ok().flatten().is_none() => + { + Some(entry.describe()) + } + _ => None, + }) } fn create(s: &mut Session, p: &Value) -> Result { @@ -232,7 +271,7 @@ fn create(s: &mut Session, p: &Value) -> Result { if ds.branch_of.is_some() { return Err(disabled(CMD, "this tab is a branch: make branches from the main file")); } - refuse_live(s, CMD, ds.uid)?; + let live = s.live_client_id(CMD, ds.uid)?; if ds.branches.len() >= figcraft_format::MAX_BRANCHES { return Err(disabled(CMD, format!("the file has the most branches it can keep ({})", figcraft_format::MAX_BRANCHES))); } @@ -240,12 +279,22 @@ fn create(s: &mut Session, p: &Value) -> Result { string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Branch {}", ds.branches.len() + 1)); let name = figcraft_format::clip(&name, figcraft_format::MAX_VERSION_NAME); let base = ds.document_itself().clone(); - // Its own id space, above every space the file, its live sessions and its branches used. - let client = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); - let client = client.checked_add(1).ok_or_else(|| disabled(CMD, "no id space left for a new branch"))?; + let no_space = || disabled(CMD, "no id space left for a new branch"); + // In a live session: your own client space, far above the main file's ids there. Otherwise its + // own id space, above every space the file, its live sessions and its branches used. + let (client, start) = match live { + Some(c) => (c, Some(live_window(s, ds.uid, c).ok_or_else(no_space)?)), + None => { + let top = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); + (top.checked_add(1).ok_or_else(no_space)?, None) + } + }; let mut head = (*base).clone(); head.name = name.clone(); head.set_client_id(client); + if let Some(start) = start { + head.reserve_ids(NodeId(start.0 - 1)); + } let id = crate::cmd::file::unique_id(|id| ds.branches.iter().any(|b| b.id == id)); let uid = ds.uid; let time = crate::cmd::comment::now_secs(); @@ -259,7 +308,13 @@ fn create(s: &mut Session, p: &Value) -> Result { head: Arc::new(head), versions: Vec::new(), }); + if live.is_some() { + s.live_branch_base(uid, &id); + } let index = open_tab(s, uid, &id)?; + if let Some(tab) = s.docs.get_mut(index) { + crate::cmd::file::add_version(tab, CREATED, "", false); + } Ok(json!({"id": id, "name": name, "index": index})) } @@ -269,11 +324,22 @@ fn open_tab(s: &mut Session, main: u64, id: &str) -> Result { s.active = i; return Ok(i); } - let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; - let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; + let live = s.live_client_id("branch.open", main)?; + let m = s.docs.iter().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; let mut doc = (*b.head).clone(); // A branch read from a file has no client id yet. doc.set_client_id(b.client); + // In your live session, a branch that would make ids right where the main file makes them (one + // read from a file that made none yet) moves up. + if live == Some(b.client) + && doc.next_id().0.abs_diff(m.doc.next_id().0) < WINDOW + && let Some(start) = live_window(s, main, b.client) + { + doc.reserve_ids(NodeId(start.0 - 1)); + } + let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; // The branch's own version history: the tab's versions while it is open ([`sync`]). let mut tab = DocState::new(doc, None).with_versions(b.versions.clone()); tab.branch_of = Some((main, id.to_string())); @@ -340,7 +406,13 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } // The main file itself, not a version shown in its place. m.end_preview(); - refuse_live(s, cmd, main_uid)?; + // Merging edits the session document, which a view link (or a file on its way) can't. + if from == Side::Branch + && let Some(why) = s.live_read_only(main_uid) + { + return Err(disabled(cmd, why)); + } + let live = s.live_client_id(cmd, main_uid)?; let picks = resolutions(cmd, p)?; let (base, head, main) = documents(s, mi, bi).ok_or(EngineError::NoDocument)?; let plan = merge::plan(&base, &head, &main).map_err(|e| EngineError::Other(e.to_string()))?; @@ -351,6 +423,14 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } let into = if from == Side::Branch { &main } else { &head }; let (mut doc, touched) = plan.merge(into, from, &pick).map_err(|e| EngineError::Other(format!("can't merge: {e}")))?; + if let Some(client) = live.filter(|_| from == Side::Branch) + && let Some(what) = foreign(&plan, &base, &main, &doc, client, s.live_branch_in_session(main_uid, &id)) + { + let why = format!( + "the branch adds {what}, made outside this live session, which the session can't take: merge it once the file has left the session" + ); + return Err(disabled(cmd, why)); + } doc.mark_dirty(touched); normalize(&mut doc, into); let doc = Arc::new(doc); @@ -383,11 +463,33 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { b.base = main; b.head = doc; } + if live.is_some() { + s.live_branch_base(main_uid, &id); + } + note(s, mi, bi, UPDATED); out["updated"] = json!(true); } Ok(out) } +/// The branch's own history entries for its creation and its updates from main. +const CREATED: &str = "Branch created"; +const UPDATED: &str = "Updated from main"; + +/// Add version `name` to the history of branch `bi` of the file in tab `mi`: its tab's when it is +/// open (it goes into the branch with [`sync`]), else the branch's. +fn note(s: &mut Session, mi: usize, bi: usize, name: &str) { + let Some((uid, id)) = s.docs.get(mi).and_then(|m| Some((m.uid, m.branches.get(bi)?.id.clone()))) else { return }; + if let Some(tab) = tab_of(s, uid, &id).and_then(|t| s.docs.get_mut(t)) { + crate::cmd::file::add_version(tab, name, "", false); + return; + } + let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) else { return }; + let mut tab = DocState::new((*b.head).clone(), None).with_versions(std::mem::take(&mut b.versions)); + crate::cmd::file::add_version(&mut tab, name, "", false); + b.versions = tab.versions; +} + /// Show merged `doc` in tab `ds`. fn show(ds: &mut DocState, doc: Arc) { ds.doc = doc; @@ -458,7 +560,7 @@ fn entry_label(plan: &Plan, entry: &Entry, value: Option<&Value>) -> Option<(&'s Entry::Variable(_) => "variable", Entry::Collection(_) => "variable collection", Entry::Comment(_) => "comment", - Entry::DevSnapshot(_) | Entry::Image(_) => return None, + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => return None, }; let base = plan.base().value(&Target::Entry(entry.clone())).ok().flatten(); let named = value.or(base.as_ref().map(|j| &j.0)).and_then(|v| v.get("name").or_else(|| v.get("text"))).and_then(Value::as_str); diff --git a/crates/engine/src/cmd/facilitate.rs b/crates/engine/src/cmd/facilitate.rs new file mode 100644 index 00000000..9a1513a6 --- /dev/null +++ b/crates/engine/src/cmd/facilitate.rs @@ -0,0 +1,373 @@ +//! Running a session on a board: a countdown everyone sees and votes on objects. +//! +//! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live +//! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are. + +use std::sync::Arc; + +use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope}; +use figcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::board::{require, text_of}; +use crate::cmd::CommandSpec; +use crate::params::{id, num, req_string, req_targets, string}; +use crate::tools::PointerEvent; +use crate::{EngineError, Result, Session, bad, spec}; + +/// Minutes a timer starts with, and adds, when none are given. +const START_MINUTES: f64 = 5.0; +const ADD_MINUTES: f64 = 1.0; +/// Votes each person gets when none are given. +const VOTES: u32 = 3; +/// A vote badge's radius on screen (logical pixels). +pub const BADGE_PX: f64 = 11.0; +/// The longest a voter name is kept. +const MAX_VOTER: usize = 64; + +pub fn specs() -> Vec { + vec![ + spec!( + "board.timer", + "Timer", + "{action: start|pause|resume|add|stop, minutes? (start: default 5; add: default 1; up to 1440)} → the timer's state (as board.timerState) one countdown per board, the same for everyone in a live session", + false, + timer_cmd + ), + spec!( + "board.timerState", + "Timer state", + "{} → {state: off|running|paused|done, left (ms), text (m:ss), ends? (ms since 1970, while running)}", + false, + |s, _| { + require(s, "board.timerState")?; + Ok(timer_json(s.doc()?.timer, now_ms())) + } + ), + spec!( + "board.vote", + "Voting session", + "{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends", + false, + vote_cmd + ), + spec!( + "board.voteResults", + "Voting results", + "{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}", + false, + |s, p| { + require(s, "board.voteResults")?; + let who = voter(s, p); + Ok(vote_json(s.doc()?, &who)) + } + ), + ] +} + +/// Milliseconds since the Unix epoch (0 if the clock is before it). +pub fn now_ms() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) +} + +/// `left` milliseconds as a clock: "4:05", "1:02:03" (whole seconds, rounded up). +pub fn clock(left: u64) -> String { + let s = left.div_ceil(1000); + let (h, m, s) = (s / 3600, s / 60 % 60, s % 60); + if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") } +} + +/// The timer's state at `now`: off, running, paused or done (run out, until someone stops it). +pub fn timer_state(t: Option, now: u64) -> &'static str { + match t { + None => "off", + Some(BoardTimer::Paused { .. }) => "paused", + Some(t) if t.left(now) == 0 => "done", + Some(_) => "running", + } +} + +fn timer_json(t: Option, now: u64) -> Value { + let left = t.map_or(0, |t| t.left(now)); + let ends = match t { + Some(BoardTimer::Running { ends }) => Some(ends), + _ => None, + }; + json!({"state": timer_state(t, now), "left": left, "text": clock(left), "ends": ends}) +} + +/// `minutes` (or `default`) as milliseconds: more than 0, at most a day. +fn duration(cmd: &str, p: &Value, default: f64) -> Result { + let m = match p.get("minutes").filter(|v| !v.is_null()) { + Some(_) => num(p, "minutes").ok_or_else(|| bad(cmd, "`minutes` must be a number"))?, + None => default, + }; + let ms = (m * 60_000.0).round(); + if !(1000.0..=TIMER_MAX_MS as f64).contains(&ms) { + return Err(bad(cmd, "`minutes` must be from 1 second to 1440 minutes")); + } + Ok(ms as u64) +} + +/// The timer after `action` at `now` (`Ok(None)`: stopped). +pub fn timer_after(cmd: &str, t: Option, action: &str, p: &Value, now: u64) -> Result> { + let off = || EngineError::Disabled(cmd.into(), "there is no timer: start one".into()); + Ok(match action { + "start" => Some(BoardTimer::Running { ends: now.saturating_add(duration(cmd, p, START_MINUTES)?) }), + "stop" => None, + "pause" => match t { + Some(r @ BoardTimer::Running { .. }) if r.left(now) > 0 => Some(BoardTimer::Paused { left: r.left(now) }), + _ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't running".into())), + }, + "resume" => match t { + Some(BoardTimer::Paused { left }) => Some(BoardTimer::Running { ends: now.saturating_add(left) }), + _ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't paused".into())), + }, + "add" => { + let more = duration(cmd, p, ADD_MINUTES)?; + let left = t.ok_or_else(off)?.left(now).saturating_add(more).min(TIMER_MAX_MS); + match t { + Some(BoardTimer::Paused { .. }) => Some(BoardTimer::Paused { left }), + _ => Some(BoardTimer::Running { ends: now.saturating_add(left) }), + } + } + other => return Err(bad(cmd, format!("unknown action `{other}` (start|pause|resume|add|stop)"))), + }) +} + +fn timer_cmd(s: &mut Session, p: &Value) -> Result { + require(s, "board.timer")?; + let action = req_string("board.timer", p, "action")?; + let now = now_ms(); + let t = timer_after("board.timer", s.doc()?.timer, &action, p, now)?; + if s.doc()?.timer != t { + s.doc_mut()?.timer = t; + } + Ok(timer_json(t, now)) +} + +/// Who votes: `voter` from the params, else you as the live session knows you (your client id +/// there, so two people of the same name vote apart), else your name. +pub fn voter(s: &Session, p: &Value) -> String { + if let Some(v) = string(p, "voter").map(|v| v.trim().chars().filter(|c| !c.is_control()).take(MAX_VOTER).collect::()) + && !v.is_empty() + { + return v; + } + let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).and_then(|ds| s.live_of(ds.uid).info.as_ref()); + match live { + Some(info) => format!("peer {}", info.me), + None => crate::cmd::comment::user_name(s), + } +} + +fn running<'a>(cmd: &str, doc: &'a Document) -> Result<&'a Vote> { + match &doc.vote { + Some(v) if !v.ended => Ok(v), + _ => Err(EngineError::Disabled(cmd.into(), "no vote is running".into())), + } +} + +fn vote_cmd(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "board.vote"; + require(s, CMD)?; + let action = req_string(CMD, p, "action")?; + let who = voter(s, p); + match action.as_str() { + "start" => start_vote(s, p, CMD)?, + "cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?, + "end" => { + running(CMD, s.doc()?)?; + if let Some(v) = s.doc_mut()?.vote.as_mut() { + v.ended = true; + } + } + "clear" => { + let doc = s.doc()?; + if doc.vote.is_some() || !doc.votes.is_empty() { + let doc = s.doc_mut()?; + doc.vote = None; + doc.votes.clear(); + } + } + other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))), + } + Ok(vote_json(s.doc()?, &who)) +} + +/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results. +fn start_vote(s: &mut Session, p: &Value, cmd: &str) -> Result<()> { + if s.doc()?.vote.as_ref().is_some_and(|v| !v.ended) { + return Err(EngineError::Disabled(cmd.into(), "a vote is already running: end it first".into())); + } + let per = match p.get("votes").filter(|v| !v.is_null()) { + Some(_) => num(p, "votes") + .filter(|n| n.fract() == 0.0 && (1.0..=f64::from(VOTES_MAX)).contains(n)) + .ok_or_else(|| bad(cmd, "`votes` must be a whole number from 1 to 99"))?, + None => f64::from(VOTES), + } as u32; + let scope = match string(p, "on").as_deref().unwrap_or("all") { + "all" => VoteScope::All, + "stickies" => VoteScope::Stickies, + "selection" => { + let doc = s.doc()?; + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| VoteScope::All.takes(doc, *i)).collect(); + if ids.is_empty() { + return Err(bad(cmd, "pick the objects to vote on (not pages)")); + } + VoteScope::Only { ids } + } + other => return Err(bad(cmd, format!("unknown `on` `{other}` (all|stickies|selection)"))), + }; + let by = crate::board::author(s); + let doc = s.doc_mut()?; + doc.votes.clear(); + doc.vote = Some(Vote { per_person: per, scope, ended: false, by }); + Ok(()) +} + +/// `board.vote {action: cast|retract, id}`: one vote of `who`'s placed on (`cast`) or taken back +/// from an object, within the running vote's scope and votes per person. +fn change_ballot(s: &mut Session, p: &Value, cmd: &str, who: &str, cast: bool) -> Result<()> { + let target = id(p, "id").ok_or_else(|| bad(cmd, "`id` must be the object to vote on"))?; + let doc = s.doc()?; + let vote = running(cmd, doc)?; + let mut ballot: Vec = vote.counted(doc, doc.votes.get(who).map_or(&[], Vec::as_slice)).collect(); + if cast { + if !vote.scope.takes(doc, target) { + return Err(match doc.node(target) { + None => EngineError::NoNode(target), + Some(_) => EngineError::Disabled(cmd.into(), "this vote isn't on that object".into()), + }); + } + if ballot.len() >= vote.per_person as usize { + return Err(EngineError::Disabled(cmd.into(), "you have no votes left: remove one first".into())); + } + ballot.push(target); + } else { + let i = + ballot.iter().rposition(|i| *i == target).ok_or_else(|| EngineError::Disabled(cmd.into(), "you have no vote on that object".into()))?; + ballot.remove(i); + } + let votes = &mut s.doc_mut()?.votes; + if ballot.is_empty() { + votes.remove(who); + } else { + votes.insert(who.to_string(), ballot); + } + Ok(()) +} + +/// `who`'s votes per object, in the order first voted on. +pub fn mine(doc: &Document, who: &str) -> Vec<(NodeId, u32)> { + let (Some(vote), Some(ballot)) = (&doc.vote, doc.votes.get(who)) else { return Vec::new() }; + let mut out: Vec<(NodeId, u32)> = Vec::new(); + for id in vote.counted(doc, ballot) { + match out.iter_mut().find(|(i, _)| *i == id) { + Some((_, n)) => *n += 1, + None => out.push((id, 1)), + } + } + out +} + +/// What an object is called in the results: its text (a sticky's note), else its name. +pub fn object_name(doc: &Document, id: NodeId) -> String { + let text = text_of(doc, id).and_then(|t| doc.node(t)).and_then(|n| n.kind.text()).map(|t| t.characters.trim().to_string()); + let name = text.filter(|t| !t.is_empty()).or_else(|| doc.node(id).map(|n| n.name.clone())).unwrap_or_default(); + let line = name.lines().next().unwrap_or_default(); + let short: String = line.chars().take(60).collect(); + if short.len() < name.len() { format!("{short}…") } else { short } +} + +fn vote_json(doc: &Document, who: &str) -> Value { + let Some(vote) = &doc.vote else { return json!({"state": "off"}) }; + let on = match &vote.scope { + VoteScope::All => json!("all"), + VoteScope::Stickies => json!("stickies"), + VoteScope::Only { ids } => json!({"selection": ids}), + }; + let mine = mine(doc, who); + let used: u32 = mine.iter().map(|m| m.1).sum(); + let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count(); + let mut out = json!({ + "state": if vote.ended { "ended" } else { "running" }, + "votesPerPerson": vote.per_person, + "on": on, + "by": vote.by, + "left": vote.per_person.saturating_sub(used), + "mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::>(), + "voters": voters, + }); + if vote.ended { + let results: Vec = + ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect(); + out["results"] = json!(results); + } + out +} + +/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4"). +pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> { + let mut last = None; + let mut rank = 0; + vote.tally(doc, &doc.votes) + .into_iter() + .enumerate() + .map(|(i, (id, n))| { + if last != Some(n) { + rank = i + 1; + last = Some(n); + } + (rank, id, n) + }) + .collect() +} + +/// Where object `b`'s vote badge sits (its top-right corner) and its radius, in page units at +/// `zoom` screen pixels per unit. +pub fn badge(b: Rect, zoom: f64) -> (Point, f64) { + (Point::new(b.x1, b.y0), BADGE_PX / zoom.max(1e-6)) +} + +impl Session { + /// A press with the Move tool while a vote runs: on one of your vote badges, or with Alt, it + /// takes that vote back; on an object the vote is on, it puts a vote there. False when the + /// press is for the tool (no vote running, or nothing to vote on there). + pub(crate) fn vote_down(&mut self, ev: &PointerEvent) -> Result { + let doc = self.doc()?; + let Some(vote) = doc.vote.as_ref().filter(|v| !v.ended) else { return Ok(false) }; + let who = voter(self, &Value::Null); + let p = ev.p(); + let on_badge = mine(doc, &who).into_iter().map(|(id, _)| id).find(|id| { + doc.world_bounds(*id).is_some_and(|b| { + let (c, r) = badge(b, ev.zoom); + (p - c).hypot() <= r + }) + }); + let hit = || { + let leaf = self.hit(doc, self.page().ok()?, p, 2.0 / ev.zoom.max(1e-6))?; + vote.scope.target(doc, leaf, figcraft_doc::geometry::select_target(doc, leaf, &[], false)) + }; + let (action, target) = match on_badge { + Some(id) => ("retract", id), + None => match hit() { + Some(id) => (if ev.alt { "retract" } else { "cast" }, id), + None => return Ok(false), + }, + }; + self.execute("board.vote", &json!({"action": action, "id": target}))?; + Ok(true) + } +} + +/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable: +/// keep them as they are now (`from`). +pub(crate) fn keep(from: &Document, to: &mut Arc) { + if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes { + let d = Arc::make_mut(to); + d.timer = from.timer; + d.vote = from.vote.clone(); + d.votes = from.votes.clone(); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c7b2c5b3..a247ed52 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ pub mod comment; pub mod component; pub mod create; pub mod edit; +pub mod facilitate; pub mod file; pub mod image; pub mod layout; @@ -113,6 +114,9 @@ fn registry() -> &'static Registry { list.extend(crate::datafill::specs()); list.extend(crate::textfit::specs()); list.extend(crate::board::specs()); + list.extend(facilitate::specs()); + list.extend(crate::board_table::specs()); + list.extend(crate::board_templates::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/facilitate_tests.rs b/crates/engine/src/facilitate_tests.rs new file mode 100644 index 00000000..1db0b67a --- /dev/null +++ b/crates/engine/src/facilitate_tests.rs @@ -0,0 +1,324 @@ +//! A board's timer and votes: the commands, what undo leaves alone, saving, live sessions, clicks +//! that vote, and junk parameters. + +use figcraft_doc::{BoardTimer, NodeId, VoteScope}; +use serde_json::{Value, json}; + +use crate::board_tests::{board, id}; +use crate::cmd::facilitate::{clock, timer_after, timer_state}; +use crate::collab_live_tests::Room; +use crate::tools::{PointerEvent, PointerKind}; +use crate::{EngineError, Session}; + +const MIN: u64 = 60_000; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn sticky(s: &mut Session, x: f64, text: &str) -> NodeId { + id(&ex(s, "board.sticky", json!({"x": x, "y": 0, "text": text})), "id") +} + +#[test] +fn the_timer_counts_from_its_end_time() { + let now = 1_000_000; + let none = json!({}); + let t = timer_after("t", None, "start", &json!({"minutes": 2}), now).unwrap(); + assert_eq!(t, Some(BoardTimer::Running { ends: now + 2 * MIN })); + assert_eq!(timer_state(t, now + MIN), "running"); + assert_eq!(timer_state(t, now + 3 * MIN), "done", "run out: done until stopped"); + // Paused half way: the time left stays put however long the pause. + let p = timer_after("t", t, "pause", &none, now + MIN).unwrap(); + assert_eq!(p, Some(BoardTimer::Paused { left: MIN })); + assert_eq!(p.unwrap().left(now + 100 * MIN), MIN); + // A minute more while paused, then on again. + let p = timer_after("t", p, "add", &none, now + 5 * MIN).unwrap(); + assert_eq!(p, Some(BoardTimer::Paused { left: 2 * MIN })); + let r = timer_after("t", p, "resume", &none, now + 10 * MIN).unwrap(); + assert_eq!(r, Some(BoardTimer::Running { ends: now + 12 * MIN })); + // Adding to a timer that ran out starts from now. + let r = timer_after("t", r, "add", &json!({"minutes": 0.5}), now + 20 * MIN).unwrap(); + assert_eq!(r, Some(BoardTimer::Running { ends: now + 20 * MIN + MIN / 2 })); + assert_eq!(timer_after("t", r, "stop", &none, now).unwrap(), None); + // What can't be done says so. + assert!(timer_after("t", None, "pause", &none, now).is_err()); + assert!(timer_after("t", None, "add", &none, now).is_err()); + assert!(timer_after("t", r, "resume", &none, now).is_err()); + assert!(timer_after("t", Some(BoardTimer::Running { ends: now }), "pause", &none, now).is_err(), "nothing left to pause"); + for m in [json!(0), json!(-1), json!(1441), json!("NaN"), json!(1e308), json!("x"), json!([1])] { + assert!(timer_after("t", None, "start", &json!({"minutes": m}), now).is_err(), "{m}"); + } + // At most a day, also by adding. + let long = timer_after("t", None, "start", &json!({"minutes": 1440}), now).unwrap(); + assert_eq!(timer_after("t", long, "add", &none, now).unwrap().unwrap().left(now), 1440 * MIN); + assert!(timer_after("t", None, "start", &json!({"minutes": 1}), u64::MAX).is_ok(), "no overflow"); +} + +#[test] +fn clocks_read_minutes_and_seconds() { + assert_eq!(clock(0), "0:00"); + assert_eq!(clock(1), "0:01", "a part of a second left still shows"); + assert_eq!(clock(5 * MIN), "5:00"); + assert_eq!(clock(65_000), "1:05"); + assert_eq!(clock(3_723_000), "1:02:03"); +} + +#[test] +fn timer_commands_drive_the_boards_timer() { + let mut s = board(); + assert_eq!(ex(&mut s, "board.timerState", json!({}))["state"], "off"); + let r = ex(&mut s, "board.timer", json!({"action": "start", "minutes": 3})); + assert_eq!(r["state"], "running"); + let left = r["left"].as_u64().unwrap(); + assert!(left > 3 * MIN - 5000 && left <= 3 * MIN, "{r}"); + assert!(r["ends"].as_u64().is_some()); + assert_eq!(ex(&mut s, "board.timer", json!({"action": "pause"}))["state"], "paused"); + let r = ex(&mut s, "board.timer", json!({"action": "add"})); + assert!(r["left"].as_u64().unwrap() > 4 * MIN - 5000, "{r}"); + assert_eq!(ex(&mut s, "board.timer", json!({"action": "resume"}))["state"], "running"); + assert_eq!(ex(&mut s, "board.timer", json!({"action": "stop"}))["state"], "off"); + assert!(s.doc().unwrap().timer.is_none()); + // Not an undo step. + assert_eq!(s.undo_depth().0, 0); + assert!(matches!(s.execute("board.timer", &json!({"action": "pause"})), Err(EngineError::Disabled(..)))); + assert!(s.execute("board.timer", &json!({"action": "jump"})).is_err()); + // Boards only. + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + for (cmd, p) in [ + ("board.timer", json!({"action": "start"})), + ("board.timerState", json!({})), + ("board.vote", json!({"action": "start"})), + ("board.voteResults", json!({})), + ] { + assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd}"); + } +} + +#[test] +fn votes_are_capped_hidden_until_the_end_and_ranked() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "Ship it"); + let b = sticky(&mut s, 300.0, "Wait"); + let c = sticky(&mut s, 600.0, "Rethink"); + let shape = id(&ex(&mut s, "board.shape", json!({"x": 0, "y": 400})), "id"); + let r = ex(&mut s, "board.vote", json!({"action": "start", "votes": 2, "on": "stickies"})); + assert_eq!((r["state"].as_str(), r["left"].as_u64(), r["on"].as_str()), (Some("running"), Some(2), Some("stickies"))); + assert!(matches!(s.execute("board.vote", &json!({"action": "start"})), Err(EngineError::Disabled(..))), "one vote at a time"); + let cast = |s: &mut Session, who: &str, on: NodeId| s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": who})); + // Ada puts both her votes on one sticky; a third is refused, and so is a shape. + cast(&mut s, "Ada", a).unwrap(); + let r = cast(&mut s, "Ada", a).unwrap(); + assert_eq!((r["left"].as_u64(), r["mine"].clone()), (Some(0), json!([{"id": a.0, "votes": 2}]))); + assert!(matches!(cast(&mut s, "Ada", b), Err(EngineError::Disabled(..))), "no votes left"); + assert!(matches!(cast(&mut s, "Bo", shape), Err(EngineError::Disabled(..))), "stickies only"); + assert!(matches!(cast(&mut s, "Bo", NodeId(99_999)), Err(EngineError::NoNode(_)))); + cast(&mut s, "Bo", b).unwrap(); + cast(&mut s, "Bo", c).unwrap(); + cast(&mut s, "Cy", b).unwrap(); + cast(&mut s, "Cy", c).unwrap(); + // Cy takes one back and puts it elsewhere. + s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).unwrap(); + assert!(s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).is_err(), "no vote there any more"); + cast(&mut s, "Cy", a).unwrap(); + // While it runs, you see your own votes only. + let r = ex(&mut s, "board.voteResults", json!({"voter": "Bo"})); + assert!(r.get("results").is_none(), "{r}"); + assert_eq!(r["mine"], json!([{"id": b.0, "votes": 1}, {"id": c.0, "votes": 1}])); + assert_eq!(r["voters"], 3); + assert_eq!(s.undo_depth().0, 4, "votes are no undo steps (only the four objects)"); + ex(&mut s, "board.vote", json!({"action": "end"})); + assert!(s.execute("board.vote", &json!({"action": "cast", "id": b, "voter": "Dee"})).is_err(), "ended"); + let r = ex(&mut s, "board.voteResults", json!({})); + assert_eq!(r["state"], "ended"); + assert_eq!( + r["results"], + json!([ + {"id": a.0, "name": "Ship it", "votes": 3, "rank": 1}, + {"id": b.0, "name": "Wait", "votes": 2, "rank": 2}, + {"id": c.0, "name": "Rethink", "votes": 1, "rank": 3}, + ]) + ); + // A deleted object's votes no longer count. + ex(&mut s, "edit.delete", json!({"ids": [a]})); + assert_eq!(ex(&mut s, "board.voteResults", json!({}))["results"][0]["id"], b.0); + ex(&mut s, "board.vote", json!({"action": "clear"})); + assert_eq!(ex(&mut s, "board.voteResults", json!({}))["state"], "off"); + assert!(s.doc().unwrap().votes.is_empty()); +} + +#[test] +fn a_vote_on_the_selection_takes_only_those() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + let b = sticky(&mut s, 300.0, "B"); + ex(&mut s, "select.set", json!({"ids": [a]})); + let r = ex(&mut s, "board.vote", json!({"action": "start", "on": "selection"})); + assert_eq!(r["on"], json!({"selection": [a.0]})); + assert_eq!(r["votesPerPerson"], 3); + assert!(s.execute("board.vote", &json!({"action": "cast", "id": b})).is_err()); + ex(&mut s, "board.vote", json!({"action": "cast", "id": a})); + // A finished vote may be replaced by a new one, which starts with no votes. + ex(&mut s, "board.vote", json!({"action": "end"})); + ex(&mut s, "board.vote", json!({"action": "start", "on": "all", "votes": 1})); + assert!(s.doc().unwrap().votes.is_empty()); + assert_eq!(s.doc().unwrap().vote.as_ref().unwrap().scope, VoteScope::All); + for bad in [json!({"votes": 0}), json!({"votes": 100}), json!({"votes": 1.5}), json!({"on": "pages"}), json!({"on": "selection", "ids": []})] { + let mut p = bad.clone(); + p["action"] = json!("start"); + ex(&mut s, "board.vote", json!({"action": "clear"})); + assert!(s.execute("board.vote", &p).is_err(), "{p}"); + } +} + +#[test] +fn undo_leaves_the_timer_and_the_vote_alone() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + ex(&mut s, "board.timer", json!({"action": "start"})); + ex(&mut s, "board.vote", json!({"action": "start"})); + ex(&mut s, "board.vote", json!({"action": "cast", "id": a})); + let b = sticky(&mut s, 300.0, "B"); + s.undo().unwrap(); + s.undo().unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.node(a).is_none() && doc.node(b).is_none(), "the stickies are undone"); + assert!(doc.timer.is_some() && doc.vote.is_some() && doc.votes.len() == 1, "the timer and the vote stay"); + ex(&mut s, "board.timer", json!({"action": "stop"})); + s.redo().unwrap(); + assert!(s.doc().unwrap().timer.is_none(), "redo doesn't bring a stopped timer back"); + assert!(s.doc().unwrap().node(a).is_some()); +} + +#[test] +fn the_timer_and_votes_save_and_old_files_open() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + ex(&mut s, "board.timer", json!({"action": "start"})); + ex(&mut s, "board.timer", json!({"action": "pause"})); + ex(&mut s, "board.vote", json!({"action": "start", "on": "stickies"})); + ex(&mut s, "board.vote", json!({"action": "cast", "id": a})); + let doc = s.doc().unwrap().clone(); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!((back.timer, &back.vote, &back.votes), (doc.timer, &doc.vote, &doc.votes)); + // A file from before: none of the fields. + let mut v = figcraft_format::to_json(&doc).unwrap(); + for k in ["timer", "vote", "votes"] { + assert!(v.as_object_mut().unwrap().remove(k).is_some(), "{k} is saved"); + } + let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap(); + assert!(old.timer.is_none() && old.vote.is_none() && old.votes.is_empty()); + // Design files don't write them. + let plain = figcraft_format::to_json(&figcraft_doc::Document::new("D")).unwrap(); + assert!(plain.get("timer").is_none() && plain.get("vote").is_none() && plain.get("votes").is_none()); +} + +#[test] +fn everyone_in_a_live_session_shares_the_timer_and_the_vote() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + let b = sticky(&mut s, 300.0, "B"); + ex(&mut s, "board.timer", json!({"action": "start", "minutes": 10})); + let mut room = Room::new(s.doc().unwrap().clone(), 2, 11); + assert!(room.s(1).doc().unwrap().timer.is_some(), "joiners get the running timer"); + let t0 = room.s(0).doc().unwrap().timer; + assert_eq!(room.s(1).doc().unwrap().timer, t0); + ex(room.s(1), "board.timer", json!({"action": "pause"})); + ex(room.s(0), "board.vote", json!({"action": "start", "votes": 2})); + room.settle(); + room.check(); + assert!(matches!(room.s(0).doc().unwrap().timer, Some(BoardTimer::Paused { .. })), "a pause reaches everyone"); + // Both vote at once: each ballot is its own entry, so neither is lost. + ex(room.s(0), "board.vote", json!({"action": "cast", "id": a})); + ex(room.s(1), "board.vote", json!({"action": "cast", "id": a})); + ex(room.s(1), "board.vote", json!({"action": "cast", "id": b})); + room.settle(); + room.check(); + assert_eq!(room.server.document().votes.len(), 2, "two voters, apart though both are called the same"); + assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["mine"], json!([{"id": a.0, "votes": 1}])); + ex(room.s(1), "board.vote", json!({"action": "end"})); + room.settle(); + let r = ex(room.s(0), "board.voteResults", json!({})); + assert_eq!(r["results"], json!([{"id": a.0, "name": "A", "votes": 2, "rank": 1}, {"id": b.0, "name": "B", "votes": 1, "rank": 2}])); + // Undo in a session takes back your own edits only, not the vote. + room.s(0).undo().unwrap(); + room.settle(); + room.check(); + assert!(room.server.document().vote.is_some()); +} + +#[test] +fn clicks_vote_while_a_vote_runs() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + ex(&mut s, "select.none", json!({})); + let click = |s: &mut Session, x: f64, y: f64, alt: bool| { + let mut down = PointerEvent::at(PointerKind::Down, x, y, 1.0); + down.alt = alt; + s.pointer(&down).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); + }; + // No vote: a click selects. + click(&mut s, 100.0, 100.0, false); + assert_eq!(s.selection(), vec![a]); + ex(&mut s, "select.none", json!({})); + ex(&mut s, "board.vote", json!({"action": "start"})); + click(&mut s, 100.0, 100.0, false); + click(&mut s, 50.0, 150.0, false); + assert!(s.selection().is_empty(), "votes, not selection"); + assert_eq!(crate::cmd::facilitate::mine(s.doc().unwrap(), "You"), vec![(a, 2)]); + // Alt takes one back; so does a click on your badge (the sticky's top-right corner). + click(&mut s, 100.0, 100.0, true); + click(&mut s, 199.0, 1.0, false); + assert!(s.doc().unwrap().votes.is_empty()); + // Empty canvas is the tool's as ever. + click(&mut s, 900.0, 900.0, false); + assert!(s.doc().unwrap().votes.is_empty()); +} + +#[test] +fn facilitation_commands_survive_junk() { + let junk = [ + json!({}), + json!(null), + json!([1]), + json!({"action": 5, "minutes": "NaN", "votes": -1, "on": [1], "id": "x", "voter": {"a": 1}}), + json!({"action": "start", "minutes": 1e308, "votes": 1e308, "on": "selection", "ids": ["x", -1]}), + json!({"action": "add", "minutes": -1e308}), + json!({"action": "cast", "id": 99_999, "voter": "\u{0}\u{7}"}), + json!({"action": "retract", "id": -1, "voter": "x".repeat(10_000)}), + json!({"action": "end"}), + json!({"action": "clear", "ids": 7}), + json!({"action": "resume", "minutes": [1]}), + ]; + for cmd in ["board.timer", "board.timerState", "board.vote", "board.voteResults"] { + for j in &junk { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + ex(&mut s, "board.timer", json!({"action": "start"})); + ex(&mut s, "board.vote", json!({"action": "start"})); + ex(&mut s, "board.vote", json!({"action": "cast", "id": a})); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + // A voter name is kept short. + assert!(s.doc().unwrap().votes.keys().all(|k| k.chars().count() <= 64)); + } + } +} + +#[test] +fn ties_share_a_rank() { + let mut s = board(); + let ids: Vec = (0..4).map(|i| sticky(&mut s, f64::from(i) * 300.0, "x")).collect(); + ex(&mut s, "board.vote", json!({"action": "start", "votes": 2})); + for (who, on) in [("a", 0), ("a", 1), ("b", 1), ("b", 0), ("c", 2), ("c", 3), ("d", 2)] { + ex(&mut s, "board.vote", json!({"action": "cast", "id": ids[on], "voter": who})); + } + ex(&mut s, "board.vote", json!({"action": "end"})); + let r = ex(&mut s, "board.voteResults", json!({})); + let ranks: Vec = r["results"].as_array().unwrap().iter().map(|x| x["rank"].as_u64().unwrap()).collect(); + assert_eq!(ranks, vec![1, 1, 1, 4], "{r}"); +} diff --git a/crates/engine/src/history_behaviour_tests.rs b/crates/engine/src/history_behaviour_tests.rs index e20f1bd1..45e0c7bf 100644 --- a/crates/engine/src/history_behaviour_tests.rs +++ b/crates/engine/src/history_behaviour_tests.rs @@ -547,7 +547,11 @@ fn branches_and_the_version_history() { // On the branch tab. // A branch keeps its own version history: a version saved on its tab is listed there only. ex(&mut s, "file.saveVersion", json!({"name": "On the branch"})); - assert_eq!(versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), [json!("On the branch")], "a branch's own history"); + assert_eq!( + versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), + [json!("Branch created"), json!("On the branch")], + "a branch's own history" + ); rect(&mut s, 900.0, 900.0, None); ex(&mut s, "doc.switch", json!({"index": 0})); let made_on_main = rect(&mut s, -500.0, 0.0, None); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79856c65..548f96ed 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,8 @@ pub mod arc_handles; pub mod background; pub mod batch; pub mod board; +pub mod board_table; +pub mod board_templates; pub mod booleans; pub mod branches; pub mod catalog; @@ -1213,8 +1215,9 @@ impl Session { } let Some(e) = ds.history.undo.pop() else { return Ok(false) }; let redo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page }; - ds.history.redo.push(redo); ds.doc = e.doc; + cmd::facilitate::keep(&redo.doc, &mut ds.doc); + ds.history.redo.push(redo); ds.selection = e.selection; if ds.doc.pages.contains(&e.page) { ds.page = e.page; @@ -1249,8 +1252,9 @@ impl Session { } let Some(e) = ds.history.redo.pop() else { return Ok(false) }; let undo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page }; - ds.history.undo.push(undo); ds.doc = e.doc; + cmd::facilitate::keep(&undo.doc, &mut ds.doc); + ds.history.undo.push(undo); ds.selection = e.selection; if ds.doc.pages.contains(&e.page) { ds.page = e.page; @@ -1638,6 +1642,8 @@ mod binding_lists_tests; #[cfg(test)] mod board_connector_tests; #[cfg(test)] +mod board_tables_tests; +#[cfg(test)] mod board_tests; #[cfg(test)] mod board_tools_tests; @@ -1646,6 +1652,8 @@ mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; #[cfg(test)] +mod branch_live_tests; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; @@ -1712,6 +1720,8 @@ mod export_depth_tests; #[cfg(test)] mod extended_chain_tests; #[cfg(test)] +mod facilitate_tests; +#[cfg(test)] mod fig_export_assets_tests; #[cfg(test)] mod fig_export_tests; @@ -1956,6 +1966,8 @@ mod version_tests; #[cfg(test)] mod view_behaviour_tests; #[cfg(test)] +mod widget_input_tests; +#[cfg(test)] mod widgets_tests; #[cfg(test)] diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index cd8a0f03..f33abd1c 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -37,7 +37,7 @@ //! short-lived presence the host passes on to everyone, never stored. A spotlight makes everyone //! follow the person who started it; moving your own view stops following, as with any follow. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::net::IpAddr; use std::sync::Arc; @@ -599,6 +599,9 @@ pub struct Live { pub spotlight: Option, /// You spotlight: everyone follows you. pub spotlighting: bool, + /// The branches of the session's file made or last updated from main in this session: every + /// layer of their base was in it (see [`crate::branches`]). + branch_bases: BTreeSet, } impl Live { @@ -1251,6 +1254,29 @@ impl Session { self.tab().and_then(|uid| self.live_read_only(uid)) } + /// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for + /// `cmd` while the session is on its way: there is no id yet. + pub(crate) fn live_client_id(&self, cmd: &str, uid: u64) -> Result> { + let Some(live) = self.live.doc(uid) else { return Ok(None) }; + match &live.client { + Some(c) => Ok(Some(c.id())), + None if live.status.active() => Err(EngineError::Disabled(cmd.into(), "wait until the file's live session has started".into())), + None => Ok(None), + } + } + + /// Branch `id` of document `uid` got its base from the session document just now. + pub(crate) fn live_branch_base(&mut self, uid: u64, id: &str) { + if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { + live.branch_bases.insert(id.to_string()); + } + } + + /// Whether branch `id` of document `uid` got its base in the live session it is in now. + pub(crate) fn live_branch_in_session(&self, uid: u64, id: &str) -> bool { + self.live.doc(uid).is_some_and(|l| l.syncing() && l.branch_bases.contains(id)) + } + /// Whether document `uid` is a live session's synced document: its edits sync and its undo /// history is the user's own. pub fn live_doc(&self, uid: u64) -> bool { diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index fc4ca775..37f22ce7 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -252,7 +252,7 @@ fn fig_copy_from_branch_exports_active_document_without_saving_main() { let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); - assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert_eq!(persisted.branches[0].versions.last().unwrap().name, "Branch version"); assert!(!s.ds().unwrap().is_dirty()); assert!(!s.docs[0].is_dirty()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ac05185f..811724f2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -415,6 +415,9 @@ fn every_command_survives_junk_params() { json!({"from": 2, "to": 1, "side": "auto"}), // Widgets: plugins, targets and fuel as junk. json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), + // Widget fields and menus: fields, items and values as junk. + json!({"id": 1, "field": {"x": 1}, "item": [1], "value": {"x": "\u{0}"}, "fuel": 1e308}), + json!({"id": [1], "field": "label\u{0}", "item": "", "value": "\u{7}".repeat(5000)}), // Plugin directories: ids, versions, servers and confirmations as junk. json!({"id": "../../x", "version": {"x": 1}, "server": "ws://\u{0}/?key=", "confirm": "yes", "path": [5], "plugin": [1], "query": 7, "sha256": 3}), json!({"id": "org.example.x", "version": "1.0.0", "server": "wss://[::1", "confirm": true, "plugin": "nope"}), diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index e236e322..2cd9bf97 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -110,6 +110,21 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - pub fn end(s: &mut Session) -> Result<()> { let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) }; s.ds_mut()?.text_edit = None; + // A widget's editable field: the typing is put back and the new value goes to the widget's + // plugin through `widget.setText`, one undo step (or none when nothing changed). + if let Some((w, field)) = s.doc().ok().and_then(|d| crate::widgets::field_of(d, te.id)) { + let value = text_of(s, te.id).map(|t| t.characters).unwrap_or_default(); + s.cancel_interaction()?; + let ds = s.ds_mut()?; + ds.selection = vec![w]; + ds.revision += 1; + if text_of(s, te.id).is_ok_and(|t| t.characters != value) + && let Err(e) = s.execute("widget.setText", &serde_json::json!({"id": w, "field": field, "value": value})) + { + s.message = Some(e.to_string()); + } + return Ok(()); + } // A board object's text (a sticky's note) stays when emptied; the object is selected again. let owner = s.doc().ok().and_then(|d| crate::board::owner_of_text(d, te.id)); let empty = owner.is_none() && text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index e63acbb6..629c9a06 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1110,6 +1110,10 @@ impl Session { PointerKind::Hover => self.hover(ev), PointerKind::Down => match tool { ToolId::Move | ToolId::Scale => { + // While a board's vote runs, a click on an object votes. + if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { + return Ok(()); + } // A click on the canvas deselects a ruler guide (the UI selects guides itself). self.ds_mut()?.guide = None; self.move_down(ev) @@ -2101,6 +2105,19 @@ impl Session { /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text /// editing (select-all on entry, word-select inside the running edit), enter vector editing /// for selected shapes, and step groups/frames one level deeper. + /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into + /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing + /// ends). Whether it was taken. + fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { + return Ok(true); + } + match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { + Some(field) => textedit::begin(self, field, None, false).map(|()| true), + None => Ok(false), + } + } + fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); @@ -2153,7 +2170,7 @@ impl Session { return crate::vectoredit::begin_many(self, &[id]); } let Some(leaf) = hit_here else { return Ok(()) }; - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(&doc, leaf, &sel)? { + if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { return Ok(()); } let target = select_target(&doc, leaf, &sel, true); diff --git a/crates/engine/src/widget_input_tests.rs b/crates/engine/src/widget_input_tests.rs new file mode 100644 index 00000000..ba202987 --- /dev/null +++ b/crates/engine/src/widget_input_tests.rs @@ -0,0 +1,211 @@ +//! Widget fields and property menus (plugin API 4): `widget.setText` and `widget.menu` call the +//! plugin back as one undo step each, from commands and from the canvas (a double-click edits a +//! field like any text); values, items and descriptions from plugins and files are checked, and +//! junk is refused and rolled back. The test plugin outputs its input; on a field edit it draws +//! nothing (the host keeps the value), on `reset` it redraws without a menu. + +use serde_json::{Value, json}; + +use crate::components::tests::{ex, idv, node}; +use crate::plugins_tests::{install, undo_len}; +use crate::tools::{PointerEvent, PointerKind}; +use crate::widgets_tests::{W, err, state}; +use crate::{Session, textedit}; +use figcraft_doc::NodeId; + +const ID: &str = "org.test.note"; + +fn manifest(id: &str, api: u32) -> Value { + json!({"id": id, "name": "Note", "version": "1.0.0", "main": "n.wasm", "api": api, "widget": true, "permissions": {"access": "read"}}) +} + +/// The note widget: a label field, a clickable square and (unless `bare`) a menu. +fn look(bare: bool) -> Value { + let root = json!({"type": "frame", "name": "Note", "layout": "vertical", "gap": 8, "padding": 10, "fill": "#FFFFFF", "children": [ + {"type": "text", "name": "Label", "field": "label", "text": "Label", "size": 14, "width": 120, "color": "#111111"}, + {"type": "rectangle", "target": "inc", "width": 20, "height": 20, "fill": "#0D99FF"}, + ]}); + if bare { + return json!({"state": {"n": 0}, "root": root}); + } + json!({"root": root, "menu": [ + {"item": "reset"}, + {"item": "big", "label": "Big", "type": "toggle", "checked": true}, + {"item": "color", "label": "Colour", "type": "color", "options": ["#ff0000", "#00FF00"], "value": "#FF0000"}, + ]}) +} + +/// The note plugin's module; `on_field` runs on a field edit (default: return without drawing). +fn note(on_field: &str) -> Vec { + let mut w = W::default(); + let (field, reset) = (w.has(r#""field":"label""#), w.has(r#""menu":"reset""#)); + let (bare, full) = (w.render(&look(true)), w.render(&look(false))); + let on_field = if on_field.is_empty() { "(return (i32.const 0))" } else { on_field }; + w.build(&format!("(if {field} (then {on_field})) (if {reset} (then {bare} (return (i32.const 0)))) {full}")) +} + +fn session() -> (Session, NodeId) { + let mut s = Session::new(); + install(&mut s, manifest(ID, 4), ¬e("")); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID, "x": 40, "y": 40}))); + (s, w) +} + +/// The text of widget `w`'s label field. +fn label(s: &Session, w: NodeId) -> String { + let first = node(s, w).children[0]; + node(s, first).kind.text().unwrap().characters.clone() +} + +#[test] +fn a_field_edit_reaches_the_state_the_layer_and_the_plugin_as_one_undo_step() { + let (mut s, w) = session(); + let list = ex(&mut s, "widget.list", json!({})); + let item = &list["widgets"][0]; + assert_eq!((item["fields"].clone(), item["targets"].clone()), (json!(["label"]), json!(["inc"]))); + assert_eq!(item["menu"][0], json!({"item": "reset", "label": "reset", "type": "action"}), "a label defaults to the item's name"); + assert_eq!(item["menu"][2]["options"], json!(["#FF0000", "#00FF00"]), "colours are normalised"); + let undo = undo_len(&s); + // Tabs become spaces and other control characters are dropped. + let inner = node(&s, w).children[1]; + let r = ex(&mut s, "widget.setText", json!({"id": inner, "field": "label", "value": "Hello\tworld\u{7}"})); + assert_eq!(r["output"]["widget"]["event"], json!({"field": "label", "value": "Hello world"})); + assert_eq!(r["output"]["widget"]["state"], json!({"label": "Hello world"}), "the plugin sees the value in the state too"); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({"label": "Hello world"}), "Hello world".into(), undo + 1)); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((state(&mut s, w), label(&s, w)), (json!({}), "Label".into())); + // Bad edits are refused and change nothing. + for (p, why) in [ + (json!({"id": w, "field": "nope", "value": "x"}), "it has: label"), + (json!({"id": w, "field": "label"}), "`value` required"), + (json!({"id": w, "field": "label", "value": 5}), "`value` required"), + (json!({"id": w, "field": "label", "value": "x".repeat(1001)}), "longer than 1000"), + (json!({"id": w, "value": "x"}), "`field` required"), + (json!({"id": 999_999, "field": "label", "value": "x"}), "isn't a widget"), + ] { + assert!(err(&mut s, "widget.setText", p.clone()).contains(why), "{p}"); + } + assert_eq!((state(&mut s, w), undo_len(&s)), (json!({}), undo)); + assert_eq!( + ex(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "x".repeat(1000)}))["state"]["label"].as_str().map(str::len), + Some(1000) + ); +} + +#[test] +fn a_runaway_or_failing_field_callback_rolls_the_value_back() { + let mut s = Session::new(); + install(&mut s, manifest(ID, 4), ¬e("(loop $l (br $l))")); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID}))); + let undo = undo_len(&s); + assert!(err(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "Hi", "fuel": 100_000})).contains("instruction budget")); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({}), "Label".into(), undo)); + // A plugin that returns an error. + install(&mut s, manifest(ID, 4), ¬e("(return (i32.const 1))")); + assert!(s.execute("widget.setText", &json!({"id": w, "field": "label", "value": "Hi"})).is_err()); + assert_eq!((state(&mut s, w), undo_len(&s)), (json!({}), undo)); +} + +#[test] +fn menu_items_call_the_plugin_back_with_their_values() { + let (mut s, w) = session(); + let event = |s: &mut Session, p: Value| ex(s, "widget.menu", p)["output"]["widget"]["event"].clone(); + // A toggle flips by default, or takes the value given; a colour is matched in any case. + assert_eq!(event(&mut s, json!({"id": w, "item": "big"})), json!({"menu": "big", "value": false})); + assert_eq!(event(&mut s, json!({"id": w, "item": "big", "value": true})), json!({"menu": "big", "value": true})); + assert_eq!(event(&mut s, json!({"id": w, "item": "color", "value": "#00ff00"})), json!({"menu": "color", "value": "#00FF00"})); + let undo = undo_len(&s); + for (p, why) in [ + (json!({"id": w, "item": "color", "value": "#0000FF"}), "must be one of #FF0000, #00FF00"), + (json!({"id": w, "item": "color"}), "must be one of"), + (json!({"id": w, "item": "color", "value": "#".repeat(5000)}), "must be one of"), + (json!({"id": w, "item": "nope"}), "it has: reset, big, color"), + (json!({"id": w}), "`item` required"), + ] { + assert!(err(&mut s, "widget.menu", p.clone()).contains(why), "{p}"); + } + assert_eq!(undo_len(&s), undo); + // An action; this one redraws without a menu, which then has nothing to choose. + let r = ex(&mut s, "widget.menu", json!({"id": w, "item": "reset"})); + assert_eq!( + (r["output"]["widget"]["event"].clone(), r["state"].clone(), r["menu"].clone()), + (json!({"menu": "reset", "value": null}), json!({"n": 0}), json!([])) + ); + assert_eq!(undo_len(&s), undo + 1); + assert!(err(&mut s, "widget.menu", json!({"id": w, "item": "reset"})).contains("no menu item")); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(crate::widgets::menu_of(s.doc().unwrap(), w).len(), 3, "undo brings the menu back"); +} + +#[test] +fn fields_and_menus_need_api_4_and_forged_menus_are_ignored() { + let mut s = Session::new(); + install(&mut s, manifest("org.test.old", 3), ¬e("")); + assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.old"})).contains("need plugin API 4")); + assert!(ex(&mut s, "widget.list", json!({}))["widgets"].as_array().unwrap().is_empty(), "rolled back"); + // A file (or a peer) can carry anything in a widget's menu data: junk reads as no menu. + let (mut s, w) = session(); + let deep = "[".repeat(100_000); + for junk in ["not json", r#"[{"item": "has space"}]"#, r#"[{"item": "c", "type": "color"}]"#, "{}", deep.as_str()] { + s.ds_mut().unwrap().doc_mut().get_mut(w).unwrap().plugin_data.insert("figcraft.widget.menu".into(), junk.to_string()); + assert!(crate::widgets::menu_of(s.doc().unwrap(), w).is_empty(), "{}", &junk[..junk.len().min(40)]); + assert!(err(&mut s, "widget.menu", json!({"id": w, "item": "c"})).contains("no menu item")); + } +} + +#[test] +fn double_clicking_a_field_edits_it_on_the_canvas() { + let (mut s, w) = session(); + let field = node(&s, w).children[0]; + // Without any font a text may have no size to click on. + let Some(b) = s.doc().unwrap().world_bounds(field).filter(|b| b.width() > 1.0 && b.height() > 1.0) else { return }; + let p = b.center(); + let at = |kind| PointerEvent::at(kind, p.x, p.y, 1.0); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::Up)).unwrap(); + assert_eq!(s.selection(), vec![w], "a click selects the widget"); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + assert_eq!(s.ds().unwrap().text_edit.as_ref().map(|te| te.id), Some(field), "a double-click edits the field"); + let undo = undo_len(&s); + // Ending without a change makes no undo step and doesn't call the plugin. + textedit::end(&mut s).unwrap(); + assert_eq!((undo_len(&s), state(&mut s, w), s.selection()), (undo, json!({}), vec![w])); + // Typing replaces the text (it's all selected); ending sends it to the plugin, one undo step. + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + textedit::insert(&mut s, "Hi").unwrap(); + ex(&mut s, "text.edit.end", json!({})); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({"label": "Hi"}), "Hi".into(), undo + 1)); + assert_eq!((s.selection(), s.ds().unwrap().text_edit.is_none()), (vec![w], true)); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(label(&s, w), "Label"); + // A failed callback leaves the field as it was and says why. + ex(&mut s, "plugin.remove", json!({"id": ID})); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + textedit::insert(&mut s, "Lost").unwrap(); + textedit::end(&mut s).unwrap(); + assert_eq!(label(&s, w), "Label"); + assert!(s.message.as_deref().is_some_and(|m| m.contains("no plugin")), "{:?}", s.message); +} + +#[test] +fn a_field_edit_syncs_in_a_live_session() { + use crate::live_tests::{push, relay, serve_as, session as live}; + let (mut s, script) = live(); + install(&mut s, manifest(ID, 4), ¬e("")); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, me, welcome) = serve_as(s.doc().unwrap(), crate::live::Access::Edit); + push(&script, vec![welcome]); + s.live_poll(); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID}))); + ex(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "Shared"})); + s.live_sync(); + let back = relay(&script, &mut server, me); + push(&script, back); + s.live_poll(); + let theirs = server.document().node(w).unwrap(); + assert_eq!(theirs.plugin_data.get("figcraft.widget.state").map(String::as_str), Some(r#"{"label":"Shared"}"#)); + assert!(theirs.plugin_data.contains_key("figcraft.widget.menu"), "the menu travels with the widget"); +} diff --git a/crates/engine/src/widgets.rs b/crates/engine/src/widgets.rs index c1ccd2a3..5d824363 100644 --- a/crates/engine/src/widgets.rs +++ b/crates/engine/src/widgets.rs @@ -6,13 +6,18 @@ //! of it the plugin named as a target calls the plugin back with that state, in a fresh sandbox //! (deterministic: the state is all it remembers), as one undo step; the plugin hands back the new //! state and layers. A callback can only rebuild its own widget; other layers change only through -//! the commands its manifest permits. See `docs/plugins.md`. +//! the commands its manifest permits. Since API 4 a text layer can be an editable field (edited on +//! the canvas like any text, or with `widget.setText`: the value goes into the state under the +//! field's name and the plugin is called back), and a widget can have a small property menu +//! (actions, toggles, a colour from a few) shown while it's selected (`widget.menu`). See +//! `docs/plugins.md`. use figcraft_color::{ImageScaleMode, Paint}; use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData}; use figcraft_geom::{Affine, Point}; -use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES}; +use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES, check_menu, check_state, clean_field}; use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender}; +pub use figcraft_plugin::{MenuItem, MenuKind}; use serde_json::{Map, Value, json}; use crate::cmd::CommandSpec; @@ -25,8 +30,12 @@ pub const DATA_PREFIX: &str = "figcraft.widget"; /// On a widget's own layer: its plugin's id, and its state (JSON). const PLUGIN: &str = "figcraft.widget"; const STATE: &str = "figcraft.widget.state"; +/// On a widget's own layer: its property menu (JSON). +const MENU: &str = "figcraft.widget.menu"; /// On a layer inside a widget: the name its plugin gets when it's clicked. const TARGET: &str = "figcraft.widget.target"; +/// On a text layer inside a widget: the name of the editable field it is. +const FIELD: &str = "figcraft.widget.field"; /// Images in a widget are at most this many pixels on a side. const MAX_IMAGE_SIDE: u32 = 4096; @@ -40,18 +49,44 @@ pub fn widget_of(doc: &Document, id: NodeId) -> Option { std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(is_widget)) } -/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget -/// and its target. The layers inside a widget are locked, so the hit stops at the widget itself; -/// the front-most target whose box holds the point wins. -pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> { +/// The part of a widget at page point `p` marked with layer data `key`, given the layer a click +/// there hits: the widget, the part and its name. The layers inside a widget are locked, so the +/// hit stops at the widget itself; the front-most part whose box holds the point wins. +fn part_at(doc: &Document, hit: NodeId, p: Point, key: &str) -> Option<(NodeId, NodeId, String)> { let w = widget_of(doc, hit)?; - let target = doc - .subtree(w) + doc.subtree(w) .into_iter() .filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p))) - .filter_map(|i| doc.node(i).filter(|n| n.visible)?.plugin_data.get(TARGET).cloned()) - .last()?; - Some((w, target)) + .filter_map(|i| Some((w, i, doc.node(i).filter(|n| n.visible)?.plugin_data.get(key).cloned()?))) + .last() +} + +/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget +/// and its target. +pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> { + part_at(doc, hit, p, TARGET).map(|(w, _, t)| (w, t)) +} + +/// The editable text field of a widget at page point `p`, given the layer a click there hits: its +/// text layer. +pub fn field_at(doc: &Document, hit: NodeId, p: Point) -> Option { + part_at(doc, hit, p, FIELD).map(|(_, f, _)| f).filter(|f| field_of(doc, *f).is_some()) +} + +/// The widget and field name text layer `id` is the editable field of. +pub fn field_of(doc: &Document, id: NodeId) -> Option<(NodeId, String)> { + let field = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)))?.plugin_data.get(FIELD)?.clone(); + Some((widget_of(doc, id)?, field)) +} + +/// Widget `w`'s property menu (empty when it has none or it's unusable: it may come from a file). +pub fn menu_of(doc: &Document, w: NodeId) -> Vec { + doc.node(w) + .and_then(|n| n.plugin_data.get(MENU)) + .filter(|s| s.len() <= MAX_STATE_BYTES) + .and_then(|s| serde_json::from_str::>(s).ok()) + .and_then(|mut m| check_menu(&mut m).is_ok().then_some(m)) + .unwrap_or_default() } /// A widget's state (an empty object when it has none or it's unusable). @@ -67,10 +102,11 @@ fn state_of(doc: &Document, w: NodeId) -> Map { .unwrap_or_default() } -/// The targets inside widget `w`, each once, in layer order. -fn targets(doc: &Document, w: NodeId) -> Vec { +/// The names of the parts inside widget `w` marked with layer data `key` (its targets or its +/// fields), each once, in layer order. +fn names(doc: &Document, w: NodeId, key: &str) -> Vec { let mut out: Vec = Vec::new(); - for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(TARGET).cloned()) { + for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) { if !out.contains(&t) { out.push(t); } @@ -89,20 +125,22 @@ fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result, fuel: Option) -> Result { +/// Call widget `w`'s plugin `p` back (`event`: what happened, a click, an edit or a menu item; +/// none when it's inserted) and build what it hands back. Part of the caller's undo step; a failure +/// rolls the whole step back. +fn callback(s: &mut Session, p: &InstalledPlugin, w: NodeId, event: Option, fuel: Option) -> Result { let m = &p.manifest; let wasm = module_of(s, p)?; - let event = target.map(|t| json!({"target": t})); + let inserting = event.is_none(); let state = state_of(s.doc()?, w); let input = json!({"api": m.api, "command": "widget", "params": {}, "selection": s.selection(), "widget": {"id": w, "state": state, "event": event}}); let mut instance = Instance::new(&wasm, &limits(fuel)).map_err(|e| plugin_error(&m.name, e))?; let mut out = call_plugin(s, m, &m.name, &mut instance, &input.to_string()).map_err(|(e, _)| e)?; match out.widget.take() { + Some(r) if m.api < 4 && r.uses_api_4() => return Err(bad("widget.render", format!("{}: fields and menus need plugin API 4", m.name))), Some(r) => build(s, &m.id, w, &r)?, - None if target.is_none() => return Err(bad("widget.insert", format!("{} drew no widget", m.name))), + None if inserting => return Err(bad("widget.insert", format!("{} drew no widget", m.name))), None => {} } Ok(out) @@ -130,6 +168,10 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<( if let Some(state) = &r.state { n.plugin_data.insert(STATE.into(), Value::Object(state.clone()).to_string()); } + match serde_json::to_string(&r.menu) { + Ok(menu) if !r.menu.is_empty() => n.plugin_data.insert(MENU.into(), menu), + _ => n.plugin_data.remove(MENU), + }; for c in &r.root.children { add(doc, w, c, cmd)?; } @@ -154,6 +196,9 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result< if !d.target.is_empty() { n.plugin_data.insert(TARGET.into(), d.target.clone()); } + if !d.field.is_empty() { + n.plugin_data.insert(FIELD.into(), d.field.clone()); + } if d.kind == WidgetKind::Image { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?; @@ -230,12 +275,13 @@ fn props(d: &WidgetNode, root: bool) -> Map { p } -/// What `widget.insert` and `widget.click` return. +/// What `widget.insert` and the callbacks return. fn report(s: &Session, w: NodeId, m: &Manifest, out: RunOutput) -> Result { let doc = s.doc()?; let output = serde_json::from_str::(&out.output).unwrap_or(Value::String(out.output)); + let (targets, fields, menu) = (names(doc, w, TARGET), names(doc, w, FIELD), menu_of(doc, w)); Ok( - json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets(doc, w), "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}), + json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets, "fields": fields, "menu": menu, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}), ) } @@ -272,21 +318,87 @@ fn insert(s: &mut Session, p: &Value) -> Result { report(s, w, m, out) } +/// The widget param `id` is, or is inside. +fn widget_param(s: &Session, cmd: &str, p: &Value) -> Result { + let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?; + widget_of(s.doc()?, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one"))) +} + +/// Call widget `w`'s plugin back with `ev`, after `prep` (which may change the widget first), as +/// one undo step. +fn event(s: &mut Session, cmd: &str, w: NodeId, ev: Value, p: &Value, prep: impl FnOnce(&mut Session) -> Result<()>) -> Result { + let plugin = s.doc()?.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default(); + let wp = widget_plugin(s, cmd, &plugin)?; + let out = s.grouped(&wp.manifest.name, |s| { + prep(s)?; + callback(s, &wp, w, Some(ev), num(p, "fuel")) + })?; + report(s, w, &wp.manifest, out) +} + fn click(s: &mut Session, p: &Value) -> Result { let cmd = "widget.click"; - let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?; + let w = widget_param(s, cmd, p)?; let target = string(p, "target").ok_or_else(|| bad(cmd, "`target` required (see widget.list)"))?; - let doc = s.doc()?; - let w = widget_of(doc, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))?; - let all = targets(doc, w); + let all = names(s.doc()?, w, TARGET); if !all.contains(&target) { return Err(bad(cmd, format!("the widget has no `{target}` to click (it has: {})", all.join(", ")))); } - let plugin = doc.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default(); - let fuel = num(p, "fuel"); - let wp = widget_plugin(s, cmd, &plugin)?; - let out = s.grouped(&wp.manifest.name, |s| callback(s, &wp, w, Some(&target), fuel))?; - report(s, w, &wp.manifest, out) + event(s, cmd, w, json!({"target": target}), p, |_| Ok(())) +} + +fn set_text(s: &mut Session, p: &Value) -> Result { + let cmd = "widget.setText"; + let w = widget_param(s, cmd, p)?; + let field = string(p, "field").ok_or_else(|| bad(cmd, "`field` required (see widget.list)"))?; + let all = names(s.doc()?, w, FIELD); + if !all.contains(&field) { + return Err(bad(cmd, format!("the widget has no field `{field}` (it has: {})", all.join(", ")))); + } + let value = match p.get("value") { + Some(Value::String(v)) => clean_field(v).map_err(|e| bad(cmd, e))?, + _ => return Err(bad(cmd, "`value` required (text)")), + }; + let ev = json!({"field": field, "value": value}); + // The value goes into the state and the field's text before the plugin hears of it, so a + // plugin that doesn't redraw still keeps (and shows) it. + event(s, cmd, w, ev, p, |s| { + let ds = s.ds_mut()?; + let before = ds.doc.clone(); + let mut state = state_of(&ds.doc, w); + state.insert(field.clone(), Value::String(value.clone())); + check_state(&state).map_err(|e| bad(cmd, e))?; + let doc = ds.doc_mut(); + let text = Map::from_iter([("characters".to_string(), Value::String(value))]); + for l in doc.subtree(w) { + if doc.node(l).is_some_and(|n| n.plugin_data.get(FIELD) == Some(&field) && matches!(n.kind, NodeKind::Text(_))) { + crate::cmd::props::apply(cmd, doc, l, &text)?; + } + } + doc.get_mut(w)?.plugin_data.insert(STATE.into(), Value::Object(state).to_string()); + crate::normalize(doc, &before); + Ok(()) + }) +} + +fn menu(s: &mut Session, p: &Value) -> Result { + let cmd = "widget.menu"; + let w = widget_param(s, cmd, p)?; + let item = string(p, "item").ok_or_else(|| bad(cmd, "`item` required (see widget.list)"))?; + let all = menu_of(s.doc()?, w); + let Some(m) = all.iter().find(|m| m.item == item) else { + let names: Vec<&str> = all.iter().map(|m| m.item.as_str()).collect(); + return Err(bad(cmd, format!("the widget has no menu item `{item}` (it has: {})", names.join(", ")))); + }; + let value = match m.kind { + MenuKind::Action => Value::Null, + MenuKind::Toggle => json!(p.get("value").and_then(Value::as_bool).unwrap_or(!m.checked)), + MenuKind::Color => { + let hex = string(p, "value").filter(|v| v.len() <= 9).and_then(|v| figcraft_color::Color::from_hex(&v)).map(|c| c.to_css_hex()); + json!(hex.filter(|h| m.options.contains(h)).ok_or_else(|| bad(cmd, format!("`value` must be one of {}", m.options.join(", "))))?) + } + }; + event(s, cmd, w, json!({"menu": item, "value": value}), p, |_| Ok(())) } /// Click `target` of the widget `id` is (or is in) on `doc`, a document that isn't one of the @@ -311,7 +423,10 @@ fn list(s: &mut Session, _p: &Value) -> Result { .filter_map(|i| { let n = doc.node(i).filter(|n| is_widget(n))?; let state = Value::Object(state_of(doc, i)); - Some(json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets(doc, i)})) + let (targets, fields, menu) = (names(doc, i, TARGET), names(doc, i, FIELD), menu_of(doc, i)); + Some( + json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets, "fields": fields, "menu": menu}), + ) }) .collect(); Ok(json!({"widgets": widgets})) @@ -333,6 +448,20 @@ pub fn specs() -> Vec { false, click ), - spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets}]}", false, list), + spec!( + "widget.setText", + "Edit widget field", + "{id (a widget or a layer in one), field (see widget.list), value (text, up to 1000 characters), fuel?} sets an editable field as typing in it would: the value goes into the state under the field's name and the plugin is called back; one undo step → like widget.insert", + false, + set_text + ), + spec!( + "widget.menu", + "Widget menu", + "{id (a widget or a layer in one), item (see widget.list), value? (a toggle: on or off, default the opposite of now; a colour: one of its options), fuel?} picks an item of the widget's property menu: the plugin is called back; one undo step → like widget.insert", + false, + menu + ), + spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list), ] } diff --git a/crates/engine/src/widgets_tests.rs b/crates/engine/src/widgets_tests.rs index f228d35c..bc952acd 100644 --- a/crates/engine/src/widgets_tests.rs +++ b/crates/engine/src/widgets_tests.rs @@ -17,13 +17,13 @@ const REPLY: usize = 200_000; /// Lays out a test plugin's strings and builds its module. #[derive(Default)] -struct W { +pub(crate) struct W { data: String, at: usize, } impl W { - fn s(&mut self, text: &str) -> (usize, usize) { + pub(crate) fn s(&mut self, text: &str) -> (usize, usize) { let a = 1000 + self.at; self.data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text)); self.at += text.len(); @@ -32,7 +32,7 @@ impl W { } /// Draw `v`; when it's refused, output why and fail. - fn render(&mut self, v: &Value) -> String { + pub(crate) fn render(&mut self, v: &Value) -> String { let (a, l) = self.s(&v.to_string()); format!( "(if (call $render (i32.const {a}) (i32.const {l})) (then (call $set_output (i32.const {REPLY}) (call $reply_read (i32.const {REPLY}) (i32.const 50000))) (return (i32.const 1))))" @@ -40,13 +40,13 @@ impl W { } /// Whether the input contains `needle`. - fn has(&mut self, needle: &str) -> String { + pub(crate) fn has(&mut self, needle: &str) -> String { let (a, l) = self.s(needle); format!("(call $has (i32.const {a}) (i32.const {l}) (local.get $n))") } /// Call `cmd`; a refusal becomes the output and the run stops (successfully). - fn call(&mut self, cmd: &str, params: &Value) -> String { + pub(crate) fn call(&mut self, cmd: &str, params: &Value) -> String { let ((a, l), (b, m)) = (self.s(cmd), self.s(¶ms.to_string())); format!( "(if (call $call (i32.const {a}) (i32.const {l}) (i32.const {b}) (i32.const {m})) @@ -55,7 +55,7 @@ impl W { } /// The module: it reads and outputs its input, then runs `body`. - fn build(self, body: &str) -> Vec { + pub(crate) fn build(self, body: &str) -> Vec { let wat = format!( r#"(module (import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32))) @@ -108,7 +108,7 @@ fn tally(extra: impl FnOnce(&mut W) -> String) -> Vec { w.build(&format!("{pre} (if {inc} (then {two} (return (i32.const 0)))) (if {dec} (then {zero} (return (i32.const 0)))) {one}")) } -fn widget_manifest(id: &str, access: &str) -> Value { +pub(crate) fn widget_manifest(id: &str, access: &str) -> Value { json!({"id": id, "name": "Tally", "version": "1.0.0", "main": "w.wasm", "api": 3, "widget": true, "permissions": {"access": access}}) } @@ -128,7 +128,7 @@ fn click(s: &mut Session, w: NodeId, target: &str) -> Value { ex(s, "widget.click", json!({"id": w, "target": target})) } -fn state(s: &mut Session, w: NodeId) -> Value { +pub(crate) fn state(s: &mut Session, w: NodeId) -> Value { let list = ex(s, "widget.list", json!({})); list["widgets"].as_array().unwrap().iter().find(|x| x["id"] == json!(w)).map(|x| x["state"].clone()).unwrap_or(Value::Null) } @@ -139,7 +139,7 @@ fn label(s: &Session, w: NodeId) -> String { node(s, first).kind.text().unwrap().characters.clone() } -fn err(s: &mut Session, cmd: &str, p: Value) -> String { +pub(crate) fn err(s: &mut Session, cmd: &str, p: Value) -> String { match s.execute(cmd, &p) { Ok(v) => panic!("{cmd} accepted {p}: {v}"), Err(e) => { diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 80a26295..31e5234b 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -11,7 +11,7 @@ pub mod widget; pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand}; pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run}; -pub use widget::{WidgetKind, WidgetNode, WidgetRender}; +pub use widget::{MenuItem, MenuKind, WidgetKind, WidgetNode, WidgetRender}; #[cfg(test)] mod bundle_tests; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index fc4839e3..92cfac7b 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -43,7 +43,7 @@ pub struct Manifest { /// The WebAssembly module, relative to the manifest (`plugin.wasm`). pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings - /// need 2, widgets 3, `saveFile` 4). + /// need 2, widgets 3, `saveFile`, widget fields and property menus 4). #[serde(default = "api_version", deserialize_with = "api_from_number_or_string")] pub api: u32, #[serde(default)] diff --git a/crates/plugin/src/widget.rs b/crates/plugin/src/widget.rs index 32ee7d34..be202c97 100644 --- a/crates/plugin/src/widget.rs +++ b/crates/plugin/src/widget.rs @@ -1,5 +1,5 @@ -//! Widgets (ABI v3): a plugin draws a layer on the canvas by describing it, and FigCraft builds the -//! layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`] +//! Widgets (ABI v3; editable text fields and a property menu since v4): a plugin draws a layer on +//! the canvas by describing it, and FigCraft builds the layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`] //! checks its size, node count, depth, numbers, strings, colours and image sizes before the engine //! sees it. The engine decides where the layers go (only inside the widget's own layer). @@ -24,6 +24,12 @@ const MAX_SIZE: f64 = 4000.0; /// Largest image (decoded bytes), and all images of one widget together. pub const MAX_IMAGE_BYTES: usize = 1024 * 1024; const MAX_IMAGES_BYTES: usize = 2 * 1024 * 1024; +/// Longest value of an editable text field (the same as any text layer's). +pub const MAX_FIELD_CHARS: usize = MAX_TEXT; +/// Most items in a widget's property menu, most colours in one colour item, longest item label. +const MAX_MENU: usize = 8; +const MAX_SWATCHES: usize = 8; +const MAX_MENU_LABEL: usize = 32; /// What a widget is made of: FigCraft's own layer kinds. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -65,6 +71,10 @@ pub struct WidgetNode { /// Clicking this layer calls the plugin back with this name (empty: not clickable). #[serde(default, skip_serializing_if = "String::is_empty")] pub target: String, + /// API 4, text layers: the text is an editable field with this name; an edit calls the plugin + /// back with the field and its new value, which is also kept in the state under this name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub field: String, /// Position in the parent frame (ignored inside auto layout and for the widget itself). #[serde(default, skip_serializing_if = "Option::is_none")] pub x: Option, @@ -119,6 +129,38 @@ pub struct WidgetRender { #[serde(default, skip_serializing_if = "Option::is_none")] pub state: Option>, pub root: WidgetNode, + /// API 4: the widget's property menu, shown while it's selected (empty: none). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub menu: Vec, +} + +/// What a property menu item is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MenuKind { + /// A button. + #[default] + Action, + /// On or off (`checked`). + Toggle, + /// One of a few colours (`options`; `value` is the current one). + Color, +} + +/// One item of a widget's property menu; choosing it calls the plugin back with `item`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct MenuItem { + pub item: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub label: String, + #[serde(rename = "type", default)] + pub kind: MenuKind, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub checked: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, } /// What one description may still use while it's checked. @@ -141,8 +183,74 @@ impl WidgetRender { return Err("widget: the root must be a frame".into()); } w.root.check(0, &mut Budget { nodes: 0, image_bytes: 0 }).map_err(|e| format!("widget: {e}"))?; + check_menu(&mut w.menu).map_err(|e| format!("widget: {e}"))?; Ok(w) } + + /// Whether it uses what plugin API 4 added (fields or a menu). + pub fn uses_api_4(&self) -> bool { + fn field(n: &WidgetNode) -> bool { + !n.field.is_empty() || n.children.iter().any(field) + } + !self.menu.is_empty() || field(&self.root) + } +} + +/// Check and normalise a property menu: at most [`MAX_MENU`] items with distinct names and short +/// labels (an item's name when it has none); a colour item has 1–[`MAX_SWATCHES`] colours, its +/// value one of them. +pub fn check_menu(menu: &mut [MenuItem]) -> Result<(), String> { + if menu.len() > MAX_MENU { + return Err(format!("a menu has more than {MAX_MENU} items")); + } + for i in 0..menu.len() { + let Some(m) = menu.get_mut(i) else { continue }; + check_id("menu item", &m.item, MAX_NAME)?; + m.label = clean("a menu label", &m.label, MAX_MENU_LABEL)?; + if m.label.is_empty() { + m.label.clone_from(&m.item); + } + let colours = m.kind == MenuKind::Color; + if m.checked && m.kind != MenuKind::Toggle { + return Err("`checked` is for toggles".into()); + } + if colours == m.options.is_empty() || m.options.len() > MAX_SWATCHES { + return Err(format!("a colour item needs 1–{MAX_SWATCHES} `options` (and only it has them)")); + } + for o in &mut m.options { + let mut c = Some(std::mem::take(o)); + colour("options", &mut c)?; + *o = c.unwrap_or_default(); + } + colour("value", &mut m.value)?; + if m.value.as_ref().is_some_and(|v| !m.options.contains(v)) { + return Err("a colour item's `value` must be one of its `options`".into()); + } + let item = m.item.clone(); + if menu.get(..i).is_some_and(|before| before.iter().any(|b| b.item == item)) { + return Err(format!("two menu items are called `{item}`")); + } + } + Ok(()) +} + +/// An editable field's new value, cleaned: line endings become `\n`, tabs spaces, and other control +/// characters are dropped; at most [`MAX_FIELD_CHARS`]. +pub fn clean_field(value: &str) -> Result { + let v: String = value + .replace("\r\n", "\n") + .chars() + .filter_map(|c| match c { + '\r' | '\n' => Some('\n'), + '\t' => Some(' '), + c if c.is_control() => None, + c => Some(c), + }) + .collect(); + if v.chars().count() > MAX_FIELD_CHARS { + return Err(format!("a field's value is longer than {MAX_FIELD_CHARS} characters")); + } + Ok(v) } /// A widget's state: a JSON object of at most [`MAX_STATE_BYTES`] with short keys. @@ -186,6 +294,9 @@ impl WidgetNode { if !self.target.is_empty() { check_id("target", &self.target, MAX_NAME)?; } + if !self.field.is_empty() { + check_id("field", &self.field, MAX_NAME)?; + } for (what, v) in [("x", self.x), ("y", self.y)] { within(what, v, -MAX_SIZE, MAX_SIZE)?; } @@ -210,6 +321,7 @@ impl WidgetNode { )?; only("text", !self.text.is_empty() || self.size.is_some() || self.color.is_some() || self.bold, kind == WidgetKind::Text)?; only("data", !self.data.is_empty(), kind == WidgetKind::Image)?; + only("field", !self.field.is_empty(), kind == WidgetKind::Text)?; if self.text.chars().count() > MAX_TEXT { return Err(format!("a text is longer than {MAX_TEXT} characters")); } diff --git a/crates/plugin/src/widget_tests.rs b/crates/plugin/src/widget_tests.rs index 35418866..1074503d 100644 --- a/crates/plugin/src/widget_tests.rs +++ b/crates/plugin/src/widget_tests.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; -use crate::widget::{MAX_IMAGE_BYTES, MAX_NODES, MAX_STATE_BYTES}; -use crate::{Host, Instance, Limits, Manifest, WidgetKind, WidgetRender}; +use crate::widget::{MAX_FIELD_CHARS, MAX_IMAGE_BYTES, MAX_NODES, MAX_STATE_BYTES, clean_field}; +use crate::{Host, Instance, Limits, Manifest, MenuKind, WidgetKind, WidgetRender}; struct Quiet; @@ -114,3 +114,47 @@ fn widgets_need_api_3() { let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap(); assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged"); } + +#[test] +fn fields_and_menus_are_checked_and_normalised() { + let root = json!({"type": "frame", "children": [{"type": "text", "field": "label", "text": "Hi"}]}); + let w = + parse(json!({"root": root, "menu": [{"item": "reset"}, {"item": "c", "type": "color", "options": ["#ff0000"], "value": "ff0000"}]})).unwrap(); + assert!(w.uses_api_4()); + assert_eq!((w.menu[0].label.as_str(), w.menu[0].kind), ("reset", MenuKind::Action), "a label defaults to the item"); + assert_eq!((w.menu[1].options.clone(), w.menu[1].value.clone()), (vec!["#FF0000".to_string()], Some("#FF0000".to_string()))); + assert!(!parse(json!({"root": {"type": "frame"}})).unwrap().uses_api_4(), "API 3 descriptions are unchanged"); + let menu = |m: Value| json!({"root": {"type": "frame"}, "menu": m}); + let field = |kind: &str, name: &str| json!({"root": {"type": "frame", "children": [{"type": kind, "field": name}]}}); + let junk = [ + menu(json!(vec![json!({"item": "a"}); 9])), + menu(json!([{"item": "a"}, {"item": "a"}])), + menu(json!([{"item": "has space"}])), + menu(json!([{"item": ""}])), + menu(json!([{"item": "a", "label": "x".repeat(33)}])), + menu(json!([{"item": "a", "label": "line\nbreak"}])), + menu(json!([{"item": "a", "type": "slider"}])), + menu(json!([{"item": "a", "checked": true}])), + menu(json!([{"item": "a", "type": "color"}])), + menu(json!([{"item": "a", "type": "color", "options": vec!["#000000"; 9]}])), + menu(json!([{"item": "a", "type": "color", "options": ["red"]}])), + menu(json!([{"item": "a", "type": "color", "options": ["#000000"], "value": "#FFFFFF"}])), + menu(json!([{"item": "a", "options": ["#000000"]}])), + menu(json!({"item": "a"})), + field("rectangle", "label"), + field("frame", "label"), + field("text", "has space"), + field("text", &"x".repeat(65)), + ]; + for j in junk { + assert!(parse(j.clone()).is_err(), "accepted {}", j.to_string().chars().take(100).collect::()); + } +} + +#[test] +fn field_values_are_cleaned_and_bounded() { + assert_eq!(clean_field("a\r\nb\rc\td\u{7}\u{0}é").unwrap(), "a\nb\nc dé"); + assert_eq!(clean_field(&"é".repeat(MAX_FIELD_CHARS)).unwrap().chars().count(), MAX_FIELD_CHARS); + assert!(clean_field(&"x".repeat(MAX_FIELD_CHARS + 1)).is_err()); + assert_eq!(clean_field("").unwrap(), ""); +} diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 84a80858..5322ca58 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -18,3 +18,13 @@ png = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } log = { workspace = true } + +# Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font +# folder are known only to Core Text. Its safe wrappers list every available font's file (Core Text +# needs unsafe FFI, which the workspace forbids). 22.0 is the first release whose +# `get_descriptors` takes ownership of the array correctly (20.x and 21.x leak it on every scan); +# it brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older +# versions other crates use. Pinned exactly: review the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +core-text = { version = "=22.0.0", default-features = false, features = ["link"] } +objc2 = "0.6" diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 32552837..ced18c81 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -817,9 +817,7 @@ impl FontDb { } self.system_scanned = true; let mut found = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); self.merge_scan(found); } @@ -924,29 +922,45 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { - continue; - } - let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; - for (index, family, style, postscript) in faces_in(&bytes) { - out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { - path: path.clone(), - index, - style, - postscript, - }); + index_font_file(&path, &meta, out); + } +} + +fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut BTreeMap)>) { + // Font-like names can also belong to pipes or devices; opening a pipe can block. + // `meta` follows symlinks, so links to regular font files remain supported. + if !meta.is_file() { + return; + } + let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); + if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(path)) { + return; + } + // Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the + // system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed + // buffer, so only those parts are ever touched. + if meta.len() > MAX_FONT_FILE { + return; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(path, l)) else { return }; + for (index, family, style, postscript) in faces_in(&bytes) { + out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { + path: path.to_path_buf(), + index, + style, + postscript, + }); + } +} + +fn scan_installed_fonts(out: &mut BTreeMap)>) { + let dirs = system_font_dirs(); + for dir in &dirs { + scan_dir(dir, 0, out); + } + for path in files_outside_dirs(font_manager_files(), &dirs) { + if let Ok(meta) = std::fs::metadata(&path) { + index_font_file(&path, &meta, out); } } } @@ -959,6 +973,28 @@ fn starts_like_a_font(path: &std::path::Path) -> bool { std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut magic)).is_ok() && matches!(&magic, &[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf") } +fn files_outside_dirs(files: Vec, dirs: &[std::path::PathBuf]) -> std::collections::BTreeSet { + files.into_iter().filter(|file| file.is_absolute() && !dirs.iter().any(|dir| file.starts_with(dir))).collect() +} + +#[cfg(target_os = "macos")] +fn font_manager_files() -> Vec { + std::panic::catch_unwind(|| { + objc2::rc::autoreleasepool(|_| { + core_text::font_collection::create_for_all_families() + .get_descriptors() + .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) + .unwrap_or_default() + }) + }) + .unwrap_or_default() +} + +#[cfg(not(target_os = "macos"))] +fn font_manager_files() -> Vec { + Vec::new() +} + /// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; @@ -1082,9 +1118,7 @@ pub fn scan_system_fonts() { if !system_fonts_opted_out() { SCANS.fetch_add(1, Ordering::Relaxed); let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let mut w = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner); w.system_scanned = true; w.merge_scan(found); @@ -1108,9 +1142,7 @@ pub fn rescan_system_fonts() -> Rescan { return Rescan { changed: added.clone(), added }; } let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); if !rescan.changed.is_empty() { crate::clear_layout_cache(); diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 72d5eafd..85d1f3a7 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -440,3 +440,54 @@ fn every_platform_has_a_thai_font_in_the_fallbacks() { assert!(at(thai).is_some_and(|i| i > latin), "{thai}"); } } + +// The paths are Unix absolute paths (font managers are a macOS thing): on Windows they aren't absolute. +#[cfg(unix)] +#[test] +fn font_manager_files_outside_the_font_folders_are_indexed_once() { + let folders = [std::path::PathBuf::from("/Library/Fonts"), std::path::PathBuf::from("/Users/a/Library/Fonts")]; + let library = std::path::PathBuf::from("/Users/a/Fonts.rightfontlibrary/fonts/I/Inter/InterVariable.ttf"); + let files = vec![ + std::path::PathBuf::from("/Library/Fonts/Arial.ttf"), + std::path::PathBuf::from("/Users/a/Library/Fonts/Sub/Brand.otf"), + library.clone(), + library.clone(), + std::path::PathBuf::from("relative/Font.ttf"), + std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), + ]; + let outside: Vec<_> = files_outside_dirs(files, &folders).into_iter().collect(); + assert_eq!(outside, [std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), library]); +} + +#[test] +fn a_font_file_outside_the_font_folders_is_indexed_from_its_path() { + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let installed = system_font_dirs() + .into_iter() + .find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf"))); + let Some(font) = installed else { return }; + let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-manager-{}", std::process::id()))); + std::fs::create_dir_all(&tmp.0).unwrap(); + let copy = tmp.0.join("Activated.ttf"); + std::fs::copy(&font, ©).unwrap(); + let mut pending = BTreeMap::new(); + index_font_file(©, &std::fs::metadata(©).unwrap(), &mut pending); + let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect(); + assert!(!paths.is_empty() && paths.iter().all(|p| *p == copy), "{paths:?}"); + let mut none = BTreeMap::new(); + index_font_file(&tmp.0, &std::fs::metadata(&tmp.0).unwrap(), &mut none); + assert!(none.is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn core_text_lists_the_installed_font_files() { + let files = font_manager_files(); + assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len()); + assert!(files.iter().all(|f| f.is_absolute())); +} diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 2ee9d325..cccea99b 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -37,6 +37,7 @@ status = "draft — needs native review" "Add a description" = "একটি বিবরণ যোগ করুন" "Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন" "Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন" +"Add a minute" = "এক মিনিট যোগ করুন" "Add a note for developers" = "ডেভেলপারদের জন্য একটি নোট যোগ করুন" "Add action" = "অ্যাকশন যোগ করুন" "Add actions for when no condition holds" = "কোনো শর্ত পূর্ণ না হলে তার জন্য অ্যাকশন যোগ করুন" @@ -44,6 +45,7 @@ status = "draft — needs native review" "Add another condition" = "আরেকটি শর্ত যোগ করুন" "Add auto layout" = "অটো লেআউট যোগ করুন" "Add background behind overlay" = "ওভারলে-এর পেছনে ব্যাকগ্রাউন্ড যোগ করুন" +"Add column" = "কলাম যোগ করুন" "Add content to this slot" = "এই স্লটে কন্টেন্ট যোগ করুন" "Add dev resource" = "ডেভ রিসোর্স যোগ করুন" "Add effect" = "ইফেক্ট যোগ করুন" @@ -63,6 +65,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "একটি ভেরিয়েবল JSON থেকে সংগ্রহ যোগ বা আপডেট করুন" "Add properties to make instances configurable" = "ইনস্ট্যান্সগুলোকে কনফিগারযোগ্য করতে প্রপার্টি যোগ করুন" "Add reaction" = "রিঅ্যাকশন যোগ করুন" +"Add row" = "সারি যোগ করুন" "Add starting point" = "শুরু করার বিন্দু যোগ করুন" "Add stroke" = "স্ট্রোক যোগ করুন" "Add variant" = "ভ্যারিয়েন্ট যোগ করুন" @@ -92,6 +95,7 @@ status = "draft — needs native review" "All comments" = "সব মন্তব্য" "All components" = "সব কম্পোনেন্ট" "All fonts" = "সব ফন্ট" +"All objects" = "সব অবজেক্ট" "All supported properties" = "সমর্থিত সব প্রপার্টি" "All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" "All variables" = "সব ভেরিয়েবল" @@ -181,6 +185,7 @@ status = "draft — needs native review" "Bottom" = "নিচে" "Bottom left radius" = "নিচের বাম ব্যাসার্ধ" "Bottom right radius" = "নিচের ডান ব্যাসার্ধ" +"Brainstorm" = "ব্রেইনস্টর্ম" "Branch" = "ব্রাঞ্চ" "Branch archived" = "ব্রাঞ্চ আর্কাইভ করা হয়েছে" "Branch merged" = "ব্রাঞ্চ মার্জ করা হয়েছে" @@ -211,6 +216,8 @@ status = "draft — needs native review" "Checking the server…" = "সার্ভার চেক করা হচ্ছে..." "Chevron" = "শেভরন" "Clear from this list" = "এই তালিকা থেকে মুছে ফেলুন" +"Clear results" = "ফলাফল মুছুন" +"Click objects to vote; click your badge to take a vote back" = "ভোট দিতে অবজেক্টে ক্লিক করুন; ভোট ফেরাতে আপনার ব্যাজে ক্লিক করুন" "Code or invite link" = "কোড বা আমন্ত্রণ লিংক" "Color profile: {name}" = "কালার প্রোফাইল: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "রঙ ও টেক্সট স্টাইল, লাইট এবং ডার্ক ভেরিয়েবল, বোতাম, ইনপুট এবং কার্ড" @@ -461,6 +468,7 @@ status = "draft — needs native review" "Empty" = "খালি" "End" = "শেষ" "End point" = "শেষ বিন্দু" +"End vote" = "ভোট শেষ করুন" "Ends the session; everyone keeps their copy" = "সেশন শেষ করে; সবাই তাদের কপি নিজের কাছে রাখে" "Enter" = "এন্টার" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "একটি figcraft-সার্ভারের ঠিকানা দিন এবং এটি যে প্লাগইনগুলো অফার করে তা দেখতে ব্রাউজ টিপুন।" @@ -537,6 +545,7 @@ status = "draft — needs native review" "Flip text to the other side" = "টেক্সট অন্য পাশে উল্টান" "Flip vertical" = "উল্লম্বভাবে উল্টান" "Flip width profile" = "প্রস্থ প্রোফাইল উল্টান" +"Flow chart" = "ফ্লো চার্ট" "Flow starting point" = "ফ্লো শুরুর বিন্দু" "Flow starting points" = "ফ্লো শুরুর বিন্দুসমূহ" "Flows" = "ফ্লোসমূহ" @@ -691,6 +700,7 @@ status = "draft — needs native review" "Jun" = "জুন" "Just now" = "এইমাত্র" "Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Kanban board" = "কানবান বোর্ড" "Keep a measurement on the canvas for developers" = "ডেভেলপারদের জন্য ক্যানভাসে একটি মাপ রাখুন" "Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" @@ -790,6 +800,7 @@ status = "draft — needs native review" "Min H" = "সর্বনিম্ন উচ্চতা" "Min W" = "সর্বনিম্ন প্রস্থ" "Minimize" = "ছোট করুন" +"Minutes" = "মিনিট" "Mirror angle" = "মিরর কোণ" "Mirror angle and length" = "মিরর কোণ ও দৈর্ঘ্য" "Mirroring" = "মিররিং" @@ -887,6 +898,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "এখনো কোনো টেক্সট স্টাইল নেই। নির্বাচিত টেক্সট থেকে একটি তৈরি করতে + এ ক্লিক করুন।" "No variables fit here." = "এখানে কোনো ভেরিয়েবল মানানসই নয়।" "No variables yet. Create them in Local variables." = "এখনো কোনো ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No votes yet" = "এখনও কোনো ভোট নেই" "Nobody else is here yet" = "এখানে আর কেউ নেই" "Noise" = "নয়েজ" "None" = "কিছুই না" @@ -973,6 +985,7 @@ status = "draft — needs native review" "Page" = "পেজ" "Pages" = "পেজসমূহ" "Pages added to the document" = "ডকুমেন্টে পেজ যোগ করা হয়েছে" +"Pause" = "বিরতি" "Pink" = "গোলাপি" "Placed on the current page" = "বর্তমান পেজে রাখা হয়েছে" "Pages: {pages}" = "পেজসমূহ: {pages}" @@ -1089,6 +1102,7 @@ status = "draft — needs native review" "Remove all" = "সব সরান" "Remove annotation" = "অ্যানোটেশন সরান" "Remove auto layout" = "অটো লেআউট সরান" +"Remove column" = "কলাম সরান" "Remove dev resource" = "ডেভ রিসোর্স সরান" "Remove export setting" = "এক্সপোর্ট সেটিংস সরান" "Remove from recents" = "সাম্প্রতিক থেকে সরান" @@ -1102,6 +1116,7 @@ status = "draft — needs native review" "Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" "Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" "Remove regions" = "অঞ্চলগুলো সরান" +"Remove row" = "সারি সরান" "Remove slot" = "স্লট সরান" "Remove starting point" = "শুরু করার বিন্দু সরান" "Remove stop" = "স্টপ সরান" @@ -1154,6 +1169,9 @@ status = "draft — needs native review" "Restore main component" = "প্রধান কম্পোনেন্ট পুনরুদ্ধার করুন" "Restore this version" = "এই সংস্করণ পুনরুদ্ধার করুন" "Restored “{name}”" = "“{name}” পুনরুদ্ধার করা হয়েছে" +"Retrospective" = "রেট্রোস্পেক্টিভ" +"Results" = "ফলাফল" +"Resume" = "আবার চালু করুন" "Reverse the order" = "ক্রম উল্টে দিন" "Reversed triangle" = "উল্টানো ত্রিভুজ" "Review and merge" = "পর্যালোচনা ও মার্জ করুন" @@ -1332,17 +1350,20 @@ status = "draft — needs native review" "Start point" = "শুরুর বিন্দু" "Start sharing" = "শেয়ার করা শুরু করুন" "Start sharing with the Share button, then pass on the code it gives" = "শেয়ার বোতাম দিয়ে শেয়ার করা শুরু করুন, তারপর এটি যে কোড দেয় তা দিন" +"Start vote" = "ভোট শুরু করুন" "Starting to share…" = "শেয়ার করা শুরু হচ্ছে..." "Status" = "স্ট্যাটাস" "Sticky" = "স্টিকি" "Sticky note" = "স্টিকি নোট" "Sticky note colors" = "স্টিকি নোটের রং" +"Sticky notes" = "স্টিকি নোট" "Sticky notes, shapes and connectors" = "স্টিকি নোট, আকৃতি ও সংযোগকারী" "Stop" = "থামুন" "Stop following {name}" = "{name}-কে অনুসরণ করা বন্ধ করুন" "Stop session" = "সেশন বন্ধ করুন" "Stop sharing" = "শেয়ার করা বন্ধ করুন" "Stop spotlighting" = "স্পটলাইটিং বন্ধ করুন" +"Stop timer" = "টাইমার থামান" "Stop {n} {hex}" = "স্টপ {n} {hex}" "Straight" = "সোজা" "Stretch" = "টেনে বড় করা (Stretch)" @@ -1428,6 +1449,7 @@ status = "draft — needs native review" "Tidy up" = "পরিপাটি করুন" "Tile" = "টাইল" "Tile type" = "টাইল টাইপ" +"Timer" = "টাইমার" "Tint" = "টিন্ট" "Title" = "শিরোনাম" "Title case" = "টাইটেল কেস" @@ -1523,6 +1545,8 @@ status = "draft — needs native review" "Visibility" = "দৃশ্যমানতা" "Visible" = "দৃশ্যমান" "Vivid" = "ভিভিড" +"Votes per person" = "জনপ্রতি ভোট" +"Voting session" = "ভোটিং সেশন" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND সেট করা আছে এবং ব্যবহারের জন্য গ্রাফিক্স API-এর নাম দেয়; এটি আনসেট করলে FigCraft কে বেছে নিতে দেয়।" "Warm" = "উষ্ণ" "Watch" = "ওয়াচ" @@ -1532,6 +1556,7 @@ status = "draft — needs native review" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL কপি করা হয়েছে" "Wedge" = "ওয়েজ" +"Weekly planner" = "সাপ্তাহিক পরিকল্পনা" "Weight" = "ওজন" "What you try here changes this preview and the code below, not the file" = "আপনি এখানে যা চেষ্টা করেন তা ফাইলটিকে নয়, কেবল এই প্রিভিউ এবং নিচের কোডটিকে পরিবর্তন করে" "What changed since it was marked ready for dev" = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কী পরিবর্তিত হয়েছে" @@ -1741,6 +1766,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n}টি লেয়ার নির্বাচিত", other = "{n}টি লেয়ার নির্বাচিত" } "{n} layers · {parts}" = { one = "{n}টি লেয়ার · {parts}", other = "{n}টি লেয়ার · {parts}" } "{n} matches" = { one = "{n}টি মিল", other = "{n}টি মিল" } +"{n} min" = "{n} মিনিট" "{n} minutes ago" = { one = "{n} মিনিট আগে", other = "{n} মিনিট আগে" } "{n} months ago" = { one = "{n} মাস আগে", other = "{n} মাস আগে" } "{n} more notes omitted from this report" = { one = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে", other = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে" } @@ -1754,6 +1780,8 @@ status = "draft — needs native review" "{n} styles" = { one = "{n}টি স্টাইল", other = "{n}টি স্টাইল" } "{n} text layers" = { one = "{n}টি টেক্সট লেয়ার", other = "{n}টি টেক্সট লেয়ার" } "{n} variables" = { one = "{n}টি ভেরিয়েবল", other = "{n}টি ভেরিয়েবল" } +"{n} votes" = { one = "{n}টি ভোট", other = "{n}টি ভোট" } +"{n} votes left" = { one = "{n}টি ভোট বাকি", other = "{n}টি ভোট বাকি" } "{n} weeks ago" = { one = "{n} সপ্তাহ আগে", other = "{n} সপ্তাহ আগে" } "{n} years ago" = { one = "{n} বছর আগে", other = "{n} বছর আগে" } "{n}d" = "{n}d" @@ -1792,6 +1820,14 @@ status = "draft — needs native review" "board.shape" = "লেখাসহ আকৃতি" "board.stamp" = "স্ট্যাম্প" "board.sticky" = "স্টিকি নোট" +"board.table" = "টেবিল" +"board.tableEdit" = "টেবিল সম্পাদনা" +"board.template" = "বোর্ড টেমপ্লেট" +"board.templates" = "বোর্ড টেমপ্লেটের তালিকা" +"board.timer" = "টাইমার" +"board.timerState" = "টাইমারের অবস্থা" +"board.vote" = "ভোটিং সেশন" +"board.voteResults" = "ভোটের ফলাফল" "devMode.addMeasurement" = "মাপ যোগ করুন" "devMode.measurements" = "মাপের তালিকা" "devMode.removeMeasurement" = "মাপ সরান" @@ -1803,3 +1839,5 @@ status = "draft — needs native review" "tool.shape" = "আকৃতি" "tool.stamp" = "স্ট্যাম্প" "tool.sticky" = "স্টিকি নোট" +"widget.menu" = "উইজেটের মেনু" +"widget.setText" = "উইজেটের ঘর সম্পাদনা" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 0a48d105..f3d04ec7 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -33,6 +33,7 @@ status = "draft — needs native review" "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í" +"Add a minute" = "Přidat minutu" "Add a note for developers" = "Přidat poznámku pro vývojáře" "Add action" = "Přidat akci" "Add actions for when no condition holds" = "Přidat akce pro případ, kdy neplatí žádná podmínka" @@ -40,6 +41,7 @@ status = "draft — needs native review" "Add another condition" = "Přidat další podmínku" "Add auto layout" = "Přidat automatické rozvržení" "Add background behind overlay" = "Přidat pozadí za překryv" +"Add column" = "Přidat sloupec" "Add content to this slot" = "Přidat obsah do tohoto slotu" "Add dev resource" = "Přidat zdroj pro vývojáře" "Add effect" = "Přidat efekt" @@ -59,6 +61,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Přidat nebo aktualizovat kolekce ze souboru JSON s proměnnými" "Add properties to make instances configurable" = "Přidejte vlastnosti pro nastavení instancí" "Add reaction" = "Přidat reakci" +"Add row" = "Přidat řádek" "Add starting point" = "Přidat počáteční bod" "Add stroke" = "Přidat tah" "Add variant" = "Přidat variantu" @@ -87,6 +90,7 @@ status = "draft — needs native review" "All comments" = "Všechny komentáře" "All components" = "Všechny komponenty" "All fonts" = "Všechna písma" +"All objects" = "Všechny objekty" "All supported properties" = "Všechny podporované vlastnosti" "All variables" = "Všechny proměnné" "Along the bottom" = "Podél dolní hrany" @@ -175,6 +179,7 @@ status = "draft — needs native review" "Bottom" = "Dole" "Bottom left radius" = "Poloměr levého dolního rohu" "Bottom right radius" = "Poloměr pravého dolního rohu" +"Brainstorm" = "Brainstorming" "Branch" = "Větev" "Branch archived" = "Větev archivována" "Branch merged" = "Větev sloučena" @@ -204,6 +209,8 @@ status = "draft — needs native review" "Checking the server…" = "Kontrola serveru…" "Chevron" = "Lomená šipka" "Clear from this list" = "Vymazat z tohoto seznamu" +"Clear results" = "Vymazat výsledky" +"Click objects to vote; click your badge to take a vote back" = "Kliknutím na objekty hlasujete; kliknutím na svůj odznak hlas vrátíte" "Code or invite link" = "Kód nebo odkaz s pozvánkou" "Color profile: {name}" = "Barevný profil: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Barevné a textové styly, proměnné pro světlý a tmavý režim, tlačítka, vstupní pole a karty" @@ -448,6 +455,7 @@ status = "draft — needs native review" "Empty" = "Prázdné" "End" = "Konec" "End point" = "Koncový bod" +"End vote" = "Ukončit hlasování" "Ends the session; everyone keeps their copy" = "Ukončí relaci; každý si ponechá svou kopii" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Zadejte adresu serveru figcraft-server a stisknutím Procházet zobrazte nabízené pluginy." @@ -523,6 +531,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Převrátit text na druhou stranu" "Flip vertical" = "Převrátit svisle" "Flip width profile" = "Převrátit profil šířky" +"Flow chart" = "Vývojový diagram" "Flow starting point" = "Počáteční bod toku" "Flow starting points" = "Počáteční body toků" "Flows" = "Toky" @@ -676,6 +685,7 @@ status = "draft — needs native review" "Jun" = "června" "Just now" = "Právě teď" "Justify" = "Do bloku" +"Kanban board" = "Kanbanová tabule" "Keep a measurement on the canvas for developers" = "Ponechat měření na plátně pro vývojáře" "Keep appearance" = "Zachovat vzhled" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachovat vzhled převede všechny hodnoty barev do profilu {profile}, aby barvy vypadaly stejně. Zachovat hodnoty ponechá čísla beze změny, takže se barvy posunou." @@ -775,6 +785,7 @@ status = "draft — needs native review" "Min H" = "Min. V" "Min W" = "Min. Š" "Minimize" = "Minimalizovat" +"Minutes" = "Minuty" "Mirror angle" = "Zrcadlit úhel" "Mirror angle and length" = "Zrcadlit úhel a délku" "Mirroring" = "Zrcadlení" @@ -869,6 +880,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Zatím žádné textové styly. Kliknutím na + vytvoříte styl z vybraného textu." "No variables fit here." = "Žádné vhodné proměnné." "No variables yet. Create them in Local variables." = "Zatím žádné proměnné. Vytvořte je v Místních proměnných." +"No votes yet" = "Zatím žádné hlasy" "Nobody else is here yet" = "Zatím tu není nikdo další" "Noise" = "Šum" "None" = "Žádné" @@ -954,6 +966,7 @@ status = "draft — needs native review" "Page deleted" = "Stránka smazána" "Pages" = "Stránky" "Pages added to the document" = "Stránky přidány do dokumentu" +"Pause" = "Pozastavit" "Pink" = "Růžová" "Placed on the current page" = "Umístěno na aktuální stránku" "Pages: {pages}" = "Stránky: {pages}" @@ -1068,6 +1081,7 @@ status = "draft — needs native review" "Remove all" = "Odebrat vše" "Remove annotation" = "Odebrat anotaci" "Remove auto layout" = "Odebrat automatické rozvržení" +"Remove column" = "Odebrat sloupec" "Remove dev resource" = "Odebrat zdroj pro vývojáře" "Remove export setting" = "Odebrat nastavení exportu" "Remove from recents" = "Odebrat z nedávných" @@ -1081,6 +1095,7 @@ status = "draft — needs native review" "Remove min and max height" = "Odebrat minimální a maximální výšku" "Remove min and max width" = "Odebrat minimální a maximální šířku" "Remove regions" = "Odebrat oblasti" +"Remove row" = "Odebrat řádek" "Remove slot" = "Odebrat slot" "Remove starting point" = "Odebrat počáteční bod" "Remove stop" = "Odebrat zarážku" @@ -1132,6 +1147,9 @@ status = "draft — needs native review" "Restore main component" = "Obnovit hlavní komponentu" "Restore this version" = "Obnovit tuto verzi" "Restored “{name}”" = "Obnoveno: „{name}“" +"Retrospective" = "Retrospektiva" +"Results" = "Výsledky" +"Resume" = "Pokračovat" "Reverse the order" = "Obrátit pořadí" "Reversed triangle" = "Obrácený trojúhelník" "Review and merge" = "Zkontrolovat a sloučit" @@ -1307,17 +1325,20 @@ status = "draft — needs native review" "Start point" = "Počáteční bod" "Start sharing" = "Zahájit sdílení" "Start sharing with the Share button, then pass on the code it gives" = "Zahajte sdílení tlačítkem Sdílet a předejte získaný kód" +"Start vote" = "Zahájit hlasování" "Starting to share…" = "Zahajování sdílení…" "Status" = "Stav" "Sticky" = "Přichycené" "Sticky note" = "Lepicí poznámka" "Sticky note colors" = "Barvy lepicích poznámek" +"Sticky notes" = "Lepicí poznámky" "Sticky notes, shapes and connectors" = "Lepicí poznámky, tvary a spojnice" "Stop" = "Zastavit" "Stop following {name}" = "Přestat sledovat: {name}" "Stop session" = "Ukončit relaci" "Stop sharing" = "Zastavit sdílení" "Stop spotlighting" = "Ukončit sdílení pohledu" +"Stop timer" = "Zastavit časovač" "Stop {n} {hex}" = "Zarážka {n} {hex}" "Straight" = "Rovná" "Stretch" = "Roztáhnout" @@ -1399,6 +1420,7 @@ status = "draft — needs native review" "Tidy up" = "Uspořádat" "Tile" = "Dlaždice" "Tile type" = "Typ dlaždic" +"Timer" = "Časovač" "Tint" = "Tónování" "Title" = "Název" "Title case" = "Velká počáteční písmena slov" @@ -1491,6 +1513,8 @@ status = "draft — needs native review" "Visibility" = "Viditelnost" "Visible" = "Viditelné" "Vivid" = "Živé" +"Votes per person" = "Hlasů na osobu" +"Voting session" = "Hlasování" "Warm" = "Teplé" "Watch" = "Hodinky" "Wavy" = "Vlnitý" @@ -1499,6 +1523,7 @@ status = "draft — needs native review" "WebSocket URL" = "URL WebSocketu" "WebSocket URL copied" = "URL WebSocketu zkopírována" "Wedge" = "Klín" +"Weekly planner" = "Týdenní plán" "Weight" = "Tloušťka" "What you try here changes this preview and the code below, not the file" = "Zdejší zkoušení mění tento náhled a kód níže, soubor zůstává beze změny" "What changed since it was marked ready for dev" = "Co se změnilo od označení jako připravené pro vývoj" @@ -1706,6 +1731,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "Vybrána {n} vrstva", few = "Vybrány {n} vrstvy", other = "Vybráno {n} vrstev" } "{n} layers · {parts}" = { one = "{n} vrstva · {parts}", few = "{n} vrstvy · {parts}", other = "{n} vrstev · {parts}" } "{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" } "{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" } "{n} more notes omitted from this report" = { one = "{n} další poznámka v této zprávě vynechána", few = "{n} další poznámky v této zprávě vynechány", other = "{n} dalších poznámek v této zprávě vynecháno" } @@ -1718,6 +1744,8 @@ status = "draft — needs native review" "{n} rows" = { one = "{n} řádek", few = "{n} řádky", other = "{n} řádků" } "{n} styles" = { one = "{n} styl", few = "{n} styly", other = "{n} stylů" } "{n} variables" = { one = "{n} proměnná", few = "{n} proměnné", other = "{n} proměnných" } +"{n} votes" = { one = "{n} hlas", few = "{n} hlasy", other = "{n} hlasů" } +"{n} votes left" = { one = "Zbývá {n} hlas", few = "Zbývají {n} hlasy", other = "Zbývá {n} hlasů" } "{n} weeks ago" = { one = "Před {n} týdnem", few = "Před {n} týdny", other = "Před {n} týdny" } "{n} years ago" = { one = "Před {n} rokem", few = "Před {n} lety", other = "Před {n} lety" } "{n}d" = "{n} d" @@ -1780,6 +1808,14 @@ status = "draft — needs native review" "board.shape" = "Tvar s textem" "board.stamp" = "Razítko" "board.sticky" = "Lepicí poznámka" +"board.table" = "Tabulka" +"board.tableEdit" = "Upravit tabulku" +"board.template" = "Šablona tabule" +"board.templates" = "Seznam šablon tabule" +"board.timer" = "Časovač" +"board.timerState" = "Stav časovače" +"board.vote" = "Hlasování" +"board.voteResults" = "Výsledky hlasování" "bool.exclude" = "Vyloučit výběr" "bool.flatten" = "Sloučit booleovskou skupinu do vektoru" "bool.intersect" = "Průnik výběru" @@ -2331,6 +2367,8 @@ status = "draft — needs native review" "widget.click" = "Kliknout na widget" "widget.insert" = "Vložit widget" "widget.list" = "Vypsat widgety" +"widget.menu" = "Nabídka widgetu" +"widget.setText" = "Upravit pole widgetu" "window.focus" = "Přenést okno do popředí" "window.new" = "Nové okno" "template.list" = "Vypsat šablony" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 0e560ad6..5a555d51 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -34,6 +34,7 @@ status = "draft — needs native review" "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" +"Add a minute" = "Eine Minute hinzufügen" "Add a note for developers" = "Notiz für Entwickler hinzufügen" "Add action" = "Aktion hinzufügen" "Add actions for when no condition holds" = "Aktionen für den Fall hinzufügen, dass keine Bedingung zutrifft" @@ -41,6 +42,7 @@ status = "draft — needs native review" "Add another condition" = "Weitere Bedingung hinzufügen" "Add auto layout" = "Auto-Layout hinzufügen" "Add background behind overlay" = "Hintergrund hinter dem Overlay hinzufügen" +"Add column" = "Spalte hinzufügen" "Add content to this slot" = "Inhalt zu diesem Slot hinzufügen" "Add dev resource" = "Entwicklerressource hinzufügen" "Add effect" = "Effekt hinzufügen" @@ -60,6 +62,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Sammlungen aus einer Variablen-JSON hinzufügen oder aktualisieren" "Add properties to make instances configurable" = "Fügen Sie Eigenschaften hinzu, um Instanzen konfigurierbar zu machen" "Add reaction" = "Reaktion hinzufügen" +"Add row" = "Zeile hinzufügen" "Add starting point" = "Startpunkt hinzufügen" "Add stroke" = "Kontur hinzufügen" "Add variant" = "Variante hinzufügen" @@ -89,6 +92,7 @@ status = "draft — needs native review" "All comments" = "Alle Kommentare" "All components" = "Alle Komponenten" "All fonts" = "Alle Schriften" +"All objects" = "Alle Objekte" "All supported properties" = "Alle unterstützten Eigenschaften" "All variables" = "Alle Variablen" "Along the bottom" = "Entlang der Unterkante" @@ -172,6 +176,7 @@ status = "draft — needs native review" "Bottom" = "Unten" "Bottom left radius" = "Radius unten links" "Bottom right radius" = "Radius unten rechts" +"Brainstorm" = "Brainstorming" "Branch" = "Branch" "Branch archived" = "Branch archiviert" "Branch merged" = "Branch zusammengeführt" @@ -200,6 +205,8 @@ status = "draft — needs native review" "Checking the server…" = "Server wird geprüft…" "Chevron" = "Chevron" "Clear from this list" = "Aus dieser Liste entfernen" +"Clear results" = "Ergebnisse löschen" +"Click objects to vote; click your badge to take a vote back" = "Klicken Sie auf Objekte, um abzustimmen; klicken Sie auf Ihr Abzeichen, um eine Stimme zurückzunehmen" "Code or invite link" = "Code oder Einladungslink" "Color profile: {name}" = "Farbprofil: {name}" "CSS custom properties…" = "Benutzerdefinierte CSS-Eigenschaften…" @@ -443,6 +450,7 @@ status = "draft — needs native review" "Empty" = "Leer" "End" = "Ende" "End point" = "Endpunkt" +"End vote" = "Abstimmung beenden" "Ends the session; everyone keeps their copy" = "Beendet die Sitzung; alle behalten ihre Kopie" "Enter" = "Eingabe" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Geben Sie die Adresse eines figcraft-server ein und drücken Sie auf Durchsuchen, um die angebotenen Plugins zu sehen." @@ -516,6 +524,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Text auf die andere Seite setzen" "Flip vertical" = "Vertikal spiegeln" "Flip width profile" = "Breitenprofil umkehren" +"Flow chart" = "Flussdiagramm" "Flow starting point" = "Startpunkt des Ablaufs" "Flow starting points" = "Startpunkte von Abläufen" "Flows" = "Abläufe" @@ -660,6 +669,7 @@ status = "draft — needs native review" "Join|stroke" = "Ecke" "Just now" = "Gerade eben" "Justify" = "Blocksatz" +"Kanban board" = "Kanban-Board" "Keep a measurement on the canvas for developers" = "Eine Messung für Entwickler auf der Arbeitsfläche behalten" "Keep appearance" = "Aussehen beibehalten" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„Aussehen beibehalten“ wandelt jeden Farbwert in {profile} um, damit die Farben gleich aussehen. „Werte beibehalten“ lässt die Zahlen unverändert, sodass sich die Farben verschieben." @@ -752,6 +762,7 @@ status = "draft — needs native review" "Min H" = "Min. H" "Min W" = "Min. B" "Minimize" = "Minimieren" +"Minutes" = "Minuten" "Mirror angle" = "Winkel spiegeln" "Mirror angle and length" = "Winkel und Länge spiegeln" "Mirroring" = "Spiegelung" @@ -843,6 +854,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Noch keine Textstile. Klicken Sie auf +, um einen aus dem ausgewählten Text zu erstellen." "No variables fit here." = "Hier passen keine Variablen." "No variables yet. Create them in Local variables." = "Noch keine Variablen. Erstellen Sie sie unter Lokale Variablen." +"No votes yet" = "Noch keine Stimmen" "Nobody else is here yet" = "Noch niemand sonst hier" "Noise" = "Rauschen" "None" = "Keine" @@ -926,6 +938,7 @@ status = "draft — needs native review" "Page deleted" = "Seite gelöscht" "Pages" = "Seiten" "Pages added to the document" = "Seiten zum Dokument hinzugefügt" +"Pause" = "Pausieren" "Pink" = "Rosa" "Placed on the current page" = "Auf der aktuellen Seite platziert" "Pages: {pages}" = "Seiten: {pages}" @@ -1037,6 +1050,7 @@ status = "draft — needs native review" "Remove all" = "Alle entfernen" "Remove annotation" = "Anmerkung entfernen" "Remove auto layout" = "Auto-Layout entfernen" +"Remove column" = "Spalte entfernen" "Remove dev resource" = "Entwicklerressource entfernen" "Remove export setting" = "Exporteinstellung entfernen" "Remove from server" = "Vom Server entfernen" @@ -1049,6 +1063,7 @@ status = "draft — needs native review" "Remove min and max height" = "Min. und max. Höhe entfernen" "Remove min and max width" = "Min. und max. Breite entfernen" "Remove regions" = "Bereiche entfernen" +"Remove row" = "Zeile entfernen" "Remove slot" = "Slot entfernen" "Remove starting point" = "Startpunkt entfernen" "Remove stop" = "Farbunterbrechung entfernen" @@ -1102,6 +1117,9 @@ status = "draft — needs native review" "Restore main component" = "Hauptkomponente wiederherstellen" "Restore this version" = "Diese Version wiederherstellen" "Restored “{name}”" = "„{name}“ wiederhergestellt" +"Retrospective" = "Retrospektive" +"Results" = "Ergebnisse" +"Resume" = "Fortsetzen" "Reverse the order" = "Reihenfolge umkehren" "Reversed triangle" = "Umgekehrtes Dreieck" "Review and merge" = "Prüfen und zusammenführen" @@ -1264,17 +1282,20 @@ status = "draft — needs native review" "Start on the Home screen" = "Mit Start beginnen" "Start point" = "Startpunkt" "Start sharing" = "Teilen starten" +"Start vote" = "Abstimmung starten" "Starting to share…" = "Teilen wird gestartet…" "Status" = "Status" "Sticky" = "Fixiert" "Sticky note" = "Haftnotiz" "Sticky note colors" = "Haftnotizfarben" +"Sticky notes" = "Haftnotizen" "Sticky notes, shapes and connectors" = "Haftnotizen, Formen und Verbinder" "Stop" = "Beenden" "Stop following {name}" = "{name} nicht mehr folgen" "Stop session" = "Sitzung beenden" "Stop sharing" = "Teilen beenden" "Stop spotlighting" = "Spotlight beenden" +"Stop timer" = "Timer stoppen" "Stop {n} {hex}" = "Farbunterbrechung {n} {hex}" "Straight" = "Gerade" "Stretch" = "Strecken" @@ -1316,6 +1337,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "Teilen Sie dem Betreiber des Servers mit, was damit nicht stimmt" "Temperature" = "Temperatur" "Template" = "Vorlage" +"Templates" = "Vorlagen" "Terms" = "Bedingungen" "Text" = "Text" "Text align" = "Textausrichtung" @@ -1360,6 +1382,7 @@ status = "draft — needs native review" "Tidy up" = "Aufräumen" "Tile" = "Kachel" "Tile type" = "Kacheltyp" +"Timer" = "Timer" "Tint" = "Tönung" "Title" = "Titel" "Title case" = "Großschreibung am Wortanfang" @@ -1451,6 +1474,8 @@ status = "draft — needs native review" "Visibility" = "Sichtbarkeit" "Visible" = "Sichtbar" "Vivid" = "Lebhaft" +"Votes per person" = "Stimmen pro Person" +"Voting session" = "Abstimmung" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND ist gesetzt und legt die Grafik-API fest; ohne diese Variable wählt FigCraft selbst." "Warm" = "Warm" "Watch" = "Smartwatch" @@ -1459,6 +1484,7 @@ status = "draft — needs native review" "WebSocket URL" = "WebSocket-URL" "WebSocket URL copied" = "WebSocket-URL kopiert" "Wedge" = "Keil" +"Weekly planner" = "Wochenplaner" "Weight" = "Stärke" "What changed since a saved version" = "Was sich seit einer gespeicherten Version geändert hat" "What changed since it was marked ready for dev" = "Was sich seit der Markierung als bereit für die Entwicklung geändert hat" @@ -1662,6 +1688,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} Ebene ausgewählt", other = "{n} Ebenen ausgewählt" } "{n} layers · {parts}" = { one = "{n} Ebene · {parts}", other = "{n} Ebenen · {parts}" } "{n} matches" = { one = "{n} Treffer", other = "{n} Treffer" } +"{n} min" = "{n} Min." "{n} minutes ago" = { one = "vor {n} Minute", other = "vor {n} Minuten" } "{n} months ago" = { one = "vor {n} Monat", other = "vor {n} Monaten" } "{n} more notes omitted from this report" = { one = "{n} weitere Notiz in diesem Bericht nicht aufgeführt", other = "{n} weitere Notizen in diesem Bericht nicht aufgeführt" } @@ -1674,6 +1701,8 @@ status = "draft — needs native review" "{n} rows" = { one = "{n} Zeile", other = "{n} Zeilen" } "{n} styles" = { one = "{n} Stil", other = "{n} Stile" } "{n} variables" = { one = "{n} Variable", other = "{n} Variablen" } +"{n} votes" = { one = "{n} Stimme", other = "{n} Stimmen" } +"{n} votes left" = { one = "Noch {n} Stimme", other = "Noch {n} Stimmen" } "{n} weeks ago" = { one = "vor {n} Woche", other = "vor {n} Wochen" } "{n} years ago" = { one = "vor {n} Jahr", other = "vor {n} Jahren" } "{n}d" = "{n} T." @@ -1736,6 +1765,14 @@ status = "draft — needs native review" "board.shape" = "Form mit Text" "board.stamp" = "Stempel" "board.sticky" = "Haftnotiz" +"board.table" = "Tabelle" +"board.tableEdit" = "Tabelle bearbeiten" +"board.template" = "Whiteboard-Vorlage" +"board.templates" = "Whiteboard-Vorlagen auflisten" +"board.timer" = "Timer" +"board.timerState" = "Timer-Status" +"board.vote" = "Abstimmung" +"board.voteResults" = "Abstimmungsergebnisse" "bool.exclude" = "Auswahl ausschließen" "bool.flatten" = "Boolesche Gruppe zusammenfügen" "bool.intersect" = "Schnittmenge der Auswahl" @@ -2280,6 +2317,8 @@ status = "draft — needs native review" "widget.click" = "Widget anklicken" "widget.insert" = "Widget einfügen" "widget.list" = "Widgets auflisten" +"widget.menu" = "Widget-Menü" +"widget.setText" = "Widget-Feld bearbeiten" "window.focus" = "Fenster in den Vordergrund holen" "window.new" = "Neues Fenster" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 532a3cfa..9be2eb21 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -36,6 +36,7 @@ name = "English" "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" +"Add a minute" = "Add a minute" "Add a note for developers" = "Add a note for developers" "Add action" = "Add action" "Add actions for when no condition holds" = "Add actions for when no condition holds" @@ -43,6 +44,7 @@ name = "English" "Add another condition" = "Add another condition" "Add auto layout" = "Add auto layout" "Add background behind overlay" = "Add background behind overlay" +"Add column" = "Add column" "Add content to this slot" = "Add content to this slot" "Add dev resource" = "Add dev resource" "Add effect" = "Add effect" @@ -62,6 +64,7 @@ name = "English" "Add or update collections from a variables JSON" = "Add or update collections from a variables JSON" "Add properties to make instances configurable" = "Add properties to make instances configurable" "Add reaction" = "Add reaction" +"Add row" = "Add row" "Add starting point" = "Add starting point" "Add stroke" = "Add stroke" "Add variant" = "Add variant" @@ -91,6 +94,7 @@ name = "English" "All comments" = "All comments" "All components" = "All components" "All fonts" = "All fonts" +"All objects" = "All objects" "All supported properties" = "All supported properties" "All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" @@ -181,6 +185,7 @@ name = "English" "Bottom left radius" = "Bottom left radius" "Bottom padding" = "Bottom padding" "Bottom right radius" = "Bottom right radius" +"Brainstorm" = "Brainstorm" "Branch" = "Branch" "Branch archived" = "Branch archived" "Branch merged" = "Branch merged" @@ -214,6 +219,8 @@ name = "English" "Checking the server…" = "Checking the server…" "Chevron" = "Chevron" "Clear from this list" = "Clear from this list" +"Clear results" = "Clear results" +"Click objects to vote; click your badge to take a vote back" = "Click objects to vote; click your badge to take a vote back" "Code or invite link" = "Code or invite link" "Color profile: {name}" = "Color profile: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards" @@ -473,6 +480,7 @@ name = "English" "Empty" = "Empty" "End" = "End" "End point" = "End point" +"End vote" = "End vote" "Ends the session; everyone keeps their copy" = "Ends the session; everyone keeps their copy" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Enter the address of a figcraft-server and press Browse to see the plugins it offers." @@ -550,6 +558,7 @@ name = "English" "Flip text to the other side" = "Flip text to the other side" "Flip vertical" = "Flip vertical" "Flip width profile" = "Flip width profile" +"Flow chart" = "Flow chart" "Flow starting point" = "Flow starting point" "Flow starting points" = "Flow starting points" "Flows" = "Flows" @@ -707,6 +716,7 @@ name = "English" "Jun" = "Jun" "Just now" = "Just now" "Justify" = "Justify" +"Kanban board" = "Kanban board" "Keep a measurement on the canvas for developers" = "Keep a measurement on the canvas for developers" "Keep appearance" = "Keep appearance" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." @@ -808,6 +818,7 @@ name = "English" "Min H" = "Min H" "Min W" = "Min W" "Minimize" = "Minimize" +"Minutes" = "Minutes" "Mirror angle" = "Mirror angle" "Mirror angle and length" = "Mirror angle and length" "Mirroring" = "Mirroring" @@ -905,6 +916,7 @@ name = "English" "No text styles yet. Click + to create one from the selected text." = "No text styles yet. Click + to create one from the selected text." "No variables fit here." = "No variables fit here." "No variables yet. Create them in Local variables." = "No variables yet. Create them in Local variables." +"No votes yet" = "No votes yet" "Nobody else is here yet" = "Nobody else is here yet" "Noise" = "Noise" "None" = "None" @@ -994,6 +1006,7 @@ name = "English" "Page deleted" = "Page deleted" "Pages" = "Pages" "Pages added to the document" = "Pages added to the document" +"Pause" = "Pause" "Pink" = "Pink" "Placed on the current page" = "Placed on the current page" "Pages: {pages}" = "Pages: {pages}" @@ -1114,6 +1127,7 @@ name = "English" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" "Remove auto layout" = "Remove auto layout" +"Remove column" = "Remove column" "Remove dev resource" = "Remove dev resource" "Remove export setting" = "Remove export setting" "Remove from recents" = "Remove from recents" @@ -1127,6 +1141,7 @@ name = "English" "Remove min and max height" = "Remove min and max height" "Remove min and max width" = "Remove min and max width" "Remove regions" = "Remove regions" +"Remove row" = "Remove row" "Remove slot" = "Remove slot" "Remove starting point" = "Remove starting point" "Remove stop" = "Remove stop" @@ -1181,6 +1196,9 @@ name = "English" "Restore main component" = "Restore main component" "Restore this version" = "Restore this version" "Restored “{name}”" = "Restored “{name}”" +"Retrospective" = "Retrospective" +"Results" = "Results" +"Resume" = "Resume" "Reverse the order" = "Reverse the order" "Reversed triangle" = "Reversed triangle" "Review and merge" = "Review and merge" @@ -1364,17 +1382,20 @@ name = "English" "Start point" = "Start point" "Start sharing" = "Start sharing" "Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives" +"Start vote" = "Start vote" "Starting to share…" = "Starting to share…" "Status" = "Status" "Sticky" = "Sticky" "Sticky note" = "Sticky note" "Sticky note colors" = "Sticky note colors" +"Sticky notes" = "Sticky notes" "Sticky notes, shapes and connectors" = "Sticky notes, shapes and connectors" "Stop" = "Stop" "Stop following {name}" = "Stop following {name}" "Stop session" = "Stop session" "Stop sharing" = "Stop sharing" "Stop spotlighting" = "Stop spotlighting" +"Stop timer" = "Stop timer" "Stop {n} {hex}" = "Stop {n} {hex}" "Straight" = "Straight" "Stretch" = "Stretch" @@ -1466,6 +1487,7 @@ name = "English" "Tidy up" = "Tidy up" "Tile" = "Tile" "Tile type" = "Tile type" +"Timer" = "Timer" "Tint" = "Tint" "Title" = "Title" "Title case" = "Title case" @@ -1564,6 +1586,8 @@ name = "English" "Visibility" = "Visibility" "Visible" = "Visible" "Vivid" = "Vivid" +"Votes per person" = "Votes per person" +"Voting session" = "Voting session" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." "Warm" = "Warm" "Watch" = "Watch" @@ -1573,6 +1597,7 @@ name = "English" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL copied" "Wedge" = "Wedge" +"Weekly planner" = "Weekly planner" "Weight" = "Weight" "What changed since a saved version" = "What changed since a saved version" "What you try here changes this preview and the code below, not the file" = "What you try here changes this preview and the code below, not the file" @@ -1787,6 +1812,7 @@ name = "English" "{n} layers selected" = { one = "{n} layer selected", other = "{n} layers selected" } "{n} layers · {parts}" = { one = "{n} layer · {parts}", other = "{n} layers · {parts}" } "{n} matches" = { one = "{n} match", other = "{n} matches" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "{n} minute ago", other = "{n} minutes ago" } "{n} months ago" = { one = "{n} month ago", other = "{n} months ago" } "{n} more notes omitted from this report" = { one = "{n} more note omitted from this report", other = "{n} more notes omitted from this report" } @@ -1800,6 +1826,8 @@ name = "English" "{n} styles" = { one = "{n} style", other = "{n} styles" } "{n} text layers" = { one = "{n} text layer", other = "{n} text layers" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } +"{n} votes" = { one = "{n} vote", other = "{n} votes" } +"{n} votes left" = { one = "{n} vote left", other = "{n} votes left" } "{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" } "{n} years ago" = { one = "{n} year ago", other = "{n} years ago" } "{n}d" = "{n}d" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 6ef81633..5b9668da 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "Add a description" = "Agregar una descripción" "Add a frame (F) or a shape (R) to get started" = "Agrega un marco (F) o una forma (R) para empezar" "Add a layer or component to tile first" = "Agrega antes una capa o un componente para el mosaico" +"Add a minute" = "Agregar un minuto" "Add a note for developers" = "Agrega una nota para desarrollo" "Add action" = "Agregar acción" "Add actions for when no condition holds" = "Agregar acciones para cuando no se cumpla ninguna condición" @@ -24,6 +25,7 @@ status = "draft — needs native review" "Add another condition" = "Agregar otra condición" "Add auto layout" = "Agregar diseño automático" "Add background behind overlay" = "Agregar fondo tras la superposición" +"Add column" = "Agregar columna" "Add content to this slot" = "Agregar contenido a esta ranura" "Add dev resource" = "Agregar recurso de desarrollo" "Add effect" = "Agregar efecto" @@ -43,6 +45,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Agregar o actualizar colecciones desde un JSON de variables" "Add properties to make instances configurable" = "Agrega propiedades para que las instancias sean configurables" "Add reaction" = "Agregar reacción" +"Add row" = "Agregar fila" "Add starting point" = "Agregar punto de inicio" "Add stroke" = "Agregar trazo" "Add variant" = "Agregar variante" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index e994258b..b5e19581 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,15 +8,22 @@ name = "Español" status = "draft — needs native review" [ui] +"Add column" = "Añadir columna" "Add measurement" = "Añadir medición" +"Add row" = "Añadir fila" +"Add a minute" = "Añadir un minuto" +"All objects" = "Todos los objetos" "Along the bottom" = "A lo largo del borde inferior" "Along the left" = "A lo largo del borde izquierdo" "Along the right" = "A lo largo del borde derecho" "Along the top" = "A lo largo del borde superior" "Arrow at the end" = "Flecha al final" "Between the selected layers" = "Entre las capas seleccionadas" +"Brainstorm" = "Lluvia de ideas" "Check" = "Visto" "Chevron" = "Cheurón" +"Clear results" = "Borrar resultados" +"Click objects to vote; click your badge to take a vote back" = "Haz clic en los objetos para votar; haz clic en tu insignia para retirar un voto" "Compatibility notes" = "Notas de compatibilidad" "Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -469,6 +476,7 @@ status = "draft — needs native review" "Empty" = "Vacía" "End" = "Final" "End point" = "Punto final" +"End vote" = "Terminar votación" "Ends the session; everyone keeps their copy" = "Termina la sesión; cada persona conserva su copia" "Enter" = "Intro" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Escribe la dirección de un figcraft-server y pulsa Explorar para ver los plugins que ofrece." @@ -545,6 +553,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Pasar el texto al otro lado" "Flip vertical" = "Voltear verticalmente" "Flip width profile" = "Invertir perfil de ancho" +"Flow chart" = "Diagrama de flujo" "Flow starting point" = "Punto de inicio del flujo" "Flow starting points" = "Puntos de inicio de flujo" "Flows" = "Flujos" @@ -702,6 +711,7 @@ status = "draft — needs native review" "Jun" = "jun" "Just now" = "Justo ahora" "Justify" = "Justificar" +"Kanban board" = "Tablero kanban" "Keep a measurement on the canvas for developers" = "Mantén una medición en el lienzo para desarrolladores" "Keep appearance" = "Mantener apariencia" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Mantener apariencia convierte cada valor de color a {profile} para que los colores se vean igual. Mantener valores deja los números como están, así que los colores cambian." @@ -802,6 +812,7 @@ status = "draft — needs native review" "Min H" = "Mín. H" "Min W" = "Mín. W" "Minimize" = "Minimizar" +"Minutes" = "Minutos" "Mirror angle" = "Reflejar ángulo" "Mirror angle and length" = "Reflejar ángulo y longitud" "Mirroring" = "Reflejo" @@ -899,6 +910,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Aún no hay estilos de texto. Haz clic en + para crear uno a partir del texto seleccionado." "No variables fit here." = "Ninguna variable encaja aquí." "No variables yet. Create them in Local variables." = "Aún no hay variables. Créalas en Variables locales." +"No votes yet" = "Aún no hay votos" "Nobody else is here yet" = "Aún no hay nadie más" "Noise" = "Ruido" "None" = "Ninguno" @@ -988,6 +1000,7 @@ status = "draft — needs native review" "Page deleted" = "Página eliminada" "Pages" = "Páginas" "Pages added to the document" = "Páginas añadidas al documento" +"Pause" = "Pausar" "Pink" = "Rosa" "Placed on the current page" = "Colocado en la página actual" "Pages: {pages}" = "Páginas: {pages}" @@ -1108,6 +1121,7 @@ status = "draft — needs native review" "Remove all" = "Quitar todo" "Remove annotation" = "Quitar anotación" "Remove auto layout" = "Quitar diseño automático" +"Remove column" = "Quitar columna" "Remove dev resource" = "Quitar recurso de desarrollo" "Remove export setting" = "Quitar ajuste de exportación" "Remove from recents" = "Quitar de recientes" @@ -1121,6 +1135,7 @@ status = "draft — needs native review" "Remove min and max height" = "Quitar alto mín. y máx." "Remove min and max width" = "Quitar ancho mín. y máx." "Remove regions" = "Quitar regiones" +"Remove row" = "Quitar fila" "Remove slot" = "Quitar ranura" "Remove starting point" = "Quitar punto de inicio" "Remove stop" = "Quitar parada" @@ -1175,6 +1190,9 @@ status = "draft — needs native review" "Restore main component" = "Restaurar componente principal" "Restore this version" = "Restaurar esta versión" "Restored “{name}”" = "“{name}” restaurada" +"Retrospective" = "Retrospectiva" +"Results" = "Resultados" +"Resume" = "Reanudar" "Reverse the order" = "Invertir el orden" "Reversed triangle" = "Triángulo invertido" "Review and merge" = "Revisar y fusionar" @@ -1357,17 +1375,20 @@ status = "draft — needs native review" "Start point" = "Punto inicial" "Start sharing" = "Empezar a compartir" "Start sharing with the Share button, then pass on the code it gives" = "Empieza a compartir con el botón Compartir y pasa el código que te da" +"Start vote" = "Iniciar votación" "Starting to share…" = "Empezando a compartir…" "Status" = "Estado" "Sticky" = "Adherido" "Sticky note" = "Nota adhesiva" "Sticky note colors" = "Colores de las notas adhesivas" +"Sticky notes" = "Notas adhesivas" "Sticky notes, shapes and connectors" = "Notas adhesivas, formas y conectores" "Stop" = "Parar" "Stop following {name}" = "Dejar de seguir a {name}" "Stop session" = "Detener sesión" "Stop sharing" = "Dejar de compartir" "Stop spotlighting" = "Salir del foco" +"Stop timer" = "Detener temporizador" "Stop {n} {hex}" = "Parada {n} {hex}" "Straight" = "Recto" "Stretch" = "Estirar" @@ -1457,6 +1478,7 @@ status = "draft — needs native review" "Tidy up" = "Ordenar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" +"Timer" = "Temporizador" "Tint" = "Matiz" "Title" = "Título" "Title case" = "Tipo título" @@ -1554,6 +1576,8 @@ status = "draft — needs native review" "Visibility" = "Visibilidad" "Visible" = "Visible" "Vivid" = "Vívido" +"Votes per person" = "Votos por persona" +"Voting session" = "Votación" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica la API gráfica que se usa; si la quitas, FigCraft elige por sí mismo." "Warm" = "Cálido" "Watch" = "Reloj" @@ -1563,6 +1587,7 @@ status = "draft — needs native review" "WebSocket URL" = "URL de WebSocket" "WebSocket URL copied" = "URL de WebSocket copiada" "Wedge" = "Cuña" +"Weekly planner" = "Planificador semanal" "Weight" = "Grosor" "What changed since a saved version" = "Qué cambió desde una versión guardada" "What you try here changes this preview and the code below, not the file" = "Lo que pruebes aquí cambia esta vista previa y el código de abajo, no el archivo" @@ -1777,6 +1802,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} capa seleccionada", other = "{n} capas seleccionadas" } "{n} layers · {parts}" = { one = "{n} capa · {parts}", other = "{n} capas · {parts}" } "{n} matches" = { one = "{n} coincidencia", other = "{n} coincidencias" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "hace {n} minuto", other = "hace {n} minutos" } "{n} months ago" = { one = "hace {n} mes", other = "hace {n} meses" } "{n} more notes omitted from this report" = { one = "{n} nota más omitida de este informe", other = "{n} notas más omitidas de este informe" } @@ -1790,6 +1816,8 @@ status = "draft — needs native review" "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } "{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } +"{n} votes" = { one = "{n} voto", other = "{n} votos" } +"{n} votes left" = { one = "Te queda {n} voto", other = "Te quedan {n} votos" } "{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" } "{n} years ago" = { one = "hace {n} año", other = "hace {n} años" } "{n}d" = "{n} d" @@ -1854,6 +1882,14 @@ status = "draft — needs native review" "board.shape" = "Forma con texto" "board.stamp" = "Sello" "board.sticky" = "Nota adhesiva" +"board.table" = "Tabla" +"board.tableEdit" = "Editar tabla" +"board.template" = "Plantilla de pizarra" +"board.templates" = "Listar plantillas de pizarra" +"board.timer" = "Temporizador" +"board.timerState" = "Estado del temporizador" +"board.vote" = "Votación" +"board.voteResults" = "Resultados de la votación" "bool.exclude" = "Excluir selección" "bool.flatten" = "Acoplar booleana" "bool.intersect" = "Intersecar selección" @@ -2423,6 +2459,8 @@ status = "draft — needs native review" "widget.click" = "Pulsar widget" "widget.insert" = "Insertar widget" "widget.list" = "Listar widgets" +"widget.menu" = "Menú del widget" +"widget.setText" = "Editar campo del widget" "window.focus" = "Traer la ventana al frente" "window.new" = "Nueva ventana" "template.list" = "Listar plantillas" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index ec4f34a8..95b9f965 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -38,6 +38,7 @@ status = "draft — needs native review" "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" +"Add a minute" = "Lisää minuutti" "Add a note for developers" = "Lisää huomautus kehittäjille" "Add action" = "Lisää toiminto" "Add actions for when no condition holds" = "Lisää toimintoja tilanteeseen, jossa mikään ehto ei täyty" @@ -45,6 +46,7 @@ status = "draft — needs native review" "Add another condition" = "Lisää toinen ehto" "Add auto layout" = "Lisää automaattinen asettelu" "Add background behind overlay" = "Lisää tausta peittokuvan taakse" +"Add column" = "Lisää sarake" "Add content to this slot" = "Lisää sisältöä tähän sisältöpaikkaan" "Add dev resource" = "Lisää kehittäjäresurssi" "Add effect" = "Lisää tehoste" @@ -64,6 +66,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Lisää tai päivitä kokoelmia muuttujien JSON-tiedostosta" "Add properties to make instances configurable" = "Lisää ominaisuuksia, jotta ilmentymiä voi mukauttaa" "Add reaction" = "Lisää reaktio" +"Add row" = "Lisää rivi" "Add starting point" = "Lisää aloituspiste" "Add stroke" = "Lisää reunaviiva" "Add variant" = "Lisää variantti" @@ -93,6 +96,7 @@ status = "draft — needs native review" "All comments" = "Kaikki kommentit" "All components" = "Kaikki komponentit" "All fonts" = "Kaikki fontit" +"All objects" = "Kaikki objektit" "All supported properties" = "Kaikki tuetut ominaisuudet" "All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" "All variables" = "Kaikki muuttujat" @@ -183,6 +187,7 @@ status = "draft — needs native review" "Bottom left radius" = "Vasemman alakulman säde" "Bottom right radius" = "Oikean alakulman säde" "Bound to {name}. Click to detach the variable." = "Sidottu muuttujaan {name}. Irrota muuttuja napsauttamalla." +"Brainstorm" = "Ideointi" "Branch" = "Haara" "Branch archived" = "Haara arkistoitu" "Branch merged" = "Haara yhdistetty" @@ -241,8 +246,10 @@ status = "draft — needs native review" "Circle" = "Ympyrä" "Clear from this list" = "Poista tästä luettelosta" "Clear recent" = "Tyhjennä viimeisimmät" +"Clear results" = "Tyhjennä tulokset" "Click + to replace mixed content" = "Korvaa vaihteleva sisältö napsauttamalla +" "Click anywhere on the canvas to leave one." = "Lisää huomautus napsauttamalla mitä tahansa piirtoalueen kohtaa." +"Click objects to vote; click your badge to take a vote back" = "Äänestä napsauttamalla objekteja; peru ääni napsauttamalla merkkiäsi" "Click or drag to merge · {alt} removes" = "Yhdistä napsauttamalla tai vetämällä · {alt} poistaa" "Click or drag to remove · {alt} merges" = "Poista napsauttamalla tai vetämällä · {alt} yhdistää" "Click to record a key" = "Tallenna näppäin napsauttamalla" @@ -463,6 +470,7 @@ status = "draft — needs native review" "Empty" = "Tyhjä" "End" = "Loppu" "End point" = "Loppupiste" +"End vote" = "Lopeta äänestys" "Ends the session; everyone keeps their copy" = "Lopettaa istunnon. Kaikki säilyttävät oman kopionsa." "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Anna figcraft-server-palvelimen osoite ja näytä sen tarjoamat liitännäiset painamalla Selaa." @@ -539,6 +547,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Siirrä teksti toiselle puolelle" "Flip vertical" = "Peilaa pystysuunnassa" "Flip width profile" = "Peilaa leveysprofiili" +"Flow chart" = "Vuokaavio" "Flow starting point" = "Kulun aloituspiste" "Flow starting points" = "Kulkujen aloituspisteet" "Flows" = "Kulut" @@ -693,6 +702,7 @@ status = "draft — needs native review" "Jun" = "kesäk." "Just now" = "Juuri nyt" "Justify" = "Tasaa molemmat reunat" +"Kanban board" = "Kanban-taulu" "Keep a measurement on the canvas for developers" = "Säilytä mittaus piirtoalueella kehittäjille" "Keep appearance" = "Säilytä ulkoasu" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." @@ -792,6 +802,7 @@ status = "draft — needs native review" "Min H" = "Vähimmäiskorkeus" "Min W" = "Vähimmäisleveys" "Minimize" = "Pienennä" +"Minutes" = "Minuutit" "Mirror angle" = "Peilaa kulma" "Mirror angle and length" = "Peilaa kulma ja pituus" "Mirroring" = "Peilaus" @@ -889,6 +900,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Ei vielä tekstityylejä. Luo tyyli valitusta tekstistä napsauttamalla +." "No variables fit here." = "Tähän ei sovi muuttujia." "No variables yet. Create them in Local variables." = "Ei vielä muuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No votes yet" = "Ei vielä ääniä" "Nobody else is here yet" = "Kukaan muu ei ole vielä paikalla" "Noise" = "Kohina" "None" = "Ei mitään" @@ -996,6 +1008,7 @@ status = "draft — needs native review" "Pattern alignment" = "Kuvion tasaus" "Pattern scale" = "Kuvion skaalaus" "Pattern source" = "Kuvion lähde" +"Pause" = "Keskeytä" "Pen" = "Kynä" "Pencil" = "Lyijykynä" "People in this file" = "Tämän tiedoston käyttäjät" @@ -1093,6 +1106,7 @@ status = "draft — needs native review" "Remove all" = "Poista kaikki" "Remove annotation" = "Poista huomautus" "Remove auto layout" = "Poista automaattinen asettelu" +"Remove column" = "Poista sarake" "Remove dev resource" = "Poista kehittäjäresurssi" "Remove export setting" = "Poista vientiasetus" "Remove from recents" = "Poista viimeisimmistä" @@ -1106,6 +1120,7 @@ status = "draft — needs native review" "Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" "Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" "Remove regions" = "Poista alueet" +"Remove row" = "Poista rivi" "Remove slot" = "Poista sisältöpaikka" "Remove starting point" = "Poista aloituspiste" "Remove stop" = "Poista liukuvärin piste" @@ -1158,6 +1173,9 @@ status = "draft — needs native review" "Restore main component" = "Palauta pääkomponentti" "Restore this version" = "Palauta tämä versio" "Restored “{name}”" = "”{name}” palautettu" +"Retrospective" = "Retrospektiivi" +"Results" = "Tulokset" +"Resume" = "Jatka" "Reverse the order" = "Käännä järjestys" "Reversed triangle" = "Käänteinen kolmio" "Review and merge" = "Tarkista ja yhdistä" @@ -1337,17 +1355,20 @@ status = "draft — needs native review" "Start point" = "Alkupiste" "Start sharing" = "Aloita jakaminen" "Start sharing with the Share button, then pass on the code it gives" = "Aloita jakaminen Jaa-painikkeella ja välitä sen antama koodi" +"Start vote" = "Aloita äänestys" "Starting to share…" = "Aloitetaan jakamista…" "Status" = "Tila" "Sticky" = "Kiinnittyvä" "Sticky note" = "Muistilappu" "Sticky note colors" = "Muistilappujen värit" +"Sticky notes" = "Muistilaput" "Sticky notes, shapes and connectors" = "Muistilaput, muodot ja yhdistimet" "Stop" = "Pysäytä" "Stop following {name}" = "Lopeta käyttäjän {name} seuraaminen" "Stop session" = "Lopeta istunto" "Stop sharing" = "Lopeta jakaminen" "Stop spotlighting" = "Lopeta esiin nostaminen" +"Stop timer" = "Pysäytä ajastin" "Stop {n} {hex}" = "Piste {n} {hex}" "Straight" = "Suora" "Stretch" = "Venytys" @@ -1433,6 +1454,7 @@ status = "draft — needs native review" "Tidy up" = "Järjestä tasavälein" "Tile" = "Laatoitus" "Tile type" = "Laatan tyyppi" +"Timer" = "Ajastin" "Tint" = "Sävytys" "Title" = "Otsikko" "Title case" = "Sanojen isot alkukirjaimet" @@ -1528,6 +1550,8 @@ status = "draft — needs native review" "Visibility" = "Näkyvyys" "Visible" = "Näkyvä" "Vivid" = "Kirkas" +"Votes per person" = "Ääniä henkilöä kohden" +"Voting session" = "Äänestys" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND on asetettu ja määrittää käytettävän grafiikkarajapinnan. Poista se käytöstä, jotta FigCraft voi valita." "Warm" = "Lämmin" "Watch" = "Seuraa" @@ -1537,6 +1561,7 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket-URL kopioitu" "Websites" = "Verkkosivustot" "Wedge" = "Kiila" +"Weekly planner" = "Viikkosuunnitelma" "Weight" = "Paksuus" "What changed since it was marked ready for dev" = "Muutokset kehitysvalmiiksi merkitsemisen jälkeen" "What is wrong with this file? The server's operator gets your report." = "Mikä tässä tiedostossa on vikana? Palvelimen ylläpitäjä saa ilmoituksesi." @@ -1747,6 +1772,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} taso valittu", other = "{n} tasoa valittu" } "{n} layers · {parts}" = { one = "{n} taso · {parts}", other = "{n} tasoa · {parts}" } "{n} matches" = { one = "{n} osuma", other = "{n} osumaa" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "{n} minuutti sitten", other = "{n} minuuttia sitten" } "{n} months ago" = { one = "{n} kuukausi sitten", other = "{n} kuukautta sitten" } "{n} more notes omitted from this report" = { one = "Tästä raportista on jätetty pois vielä {n} huomautus", other = "Tästä raportista on jätetty pois vielä {n} huomautusta" } @@ -1760,6 +1786,8 @@ status = "draft — needs native review" "{n} styles" = { one = "{n} tyyli", other = "{n} tyyliä" } "{n} text layers" = { one = "{n} tekstitaso", other = "{n} tekstitasoa" } "{n} variables" = { one = "{n} muuttuja", other = "{n} muuttujaa" } +"{n} votes" = { one = "{n} ääni", other = "{n} ääntä" } +"{n} votes left" = { one = "{n} ääni jäljellä", other = "{n} ääntä jäljellä" } "{n} weeks ago" = { one = "{n} viikko sitten", other = "{n} viikkoa sitten" } "{n} years ago" = { one = "{n} vuosi sitten", other = "{n} vuotta sitten" } "{n}d" = "{n}pv" @@ -1822,6 +1850,14 @@ status = "draft — needs native review" "board.shape" = "Muoto ja teksti" "board.stamp" = "Leima" "board.sticky" = "Muistilappu" +"board.table" = "Taulukko" +"board.tableEdit" = "Muokkaa taulukkoa" +"board.template" = "Taulun malli" +"board.templates" = "Luettele taulun mallit" +"board.timer" = "Ajastin" +"board.timerState" = "Ajastimen tila" +"board.vote" = "Äänestys" +"board.voteResults" = "Äänestyksen tulokset" "bool.exclude" = "Sulje valinnan päällekkäinen alue pois" "bool.flatten" = "Yhdistä Boolen muoto vektoriksi" "bool.intersect" = "Leikkaa valinnan yhteinen alue" @@ -2380,6 +2416,8 @@ status = "draft — needs native review" "widget.click" = "Napsauta pienoisohjelmaa" "widget.insert" = "Lisää pienoisohjelma" "widget.list" = "Luettele pienoisohjelmat" +"widget.menu" = "Pienoisohjelman valikko" +"widget.setText" = "Muokkaa pienoisohjelman kenttää" "window.focus" = "Tuo ikkuna etummaiseksi" "window.new" = "Uusi ikkuna" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 984c667c..9a6b3c67 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -37,6 +37,7 @@ status = "draft — needs native review" "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" +"Add a minute" = "Ajouter une minute" "Add a note for developers" = "Ajouter une note pour les développeurs" "Add action" = "Ajouter une action" "Add actions for when no condition holds" = "Ajouter des actions pour le cas où aucune condition n’est remplie" @@ -44,6 +45,7 @@ status = "draft — needs native review" "Add another condition" = "Ajouter une autre condition" "Add auto layout" = "Ajouter la mise en page auto" "Add background behind overlay" = "Ajouter un fond derrière la superposition" +"Add column" = "Ajouter une colonne" "Add content to this slot" = "Ajouter du contenu à cet emplacement" "Add dev resource" = "Ajouter une ressource de développement" "Add effect" = "Ajouter un effet" @@ -63,6 +65,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Ajouter ou mettre à jour des collections depuis un JSON de variables" "Add properties to make instances configurable" = "Ajoutez des propriétés pour rendre les instances configurables" "Add reaction" = "Ajouter une réaction" +"Add row" = "Ajouter une ligne" "Add starting point" = "Ajouter un point de départ" "Add stroke" = "Ajouter un contour" "Add variant" = "Ajouter une variante" @@ -92,6 +95,7 @@ status = "draft — needs native review" "All comments" = "Tous les commentaires" "All components" = "Tous les composants" "All fonts" = "Toutes les polices" +"All objects" = "Tous les objets" "All supported properties" = "Toutes les propriétés prises en charge" "All variables" = "Toutes les variables" "Along the bottom" = "Le long du bord inférieur" @@ -181,6 +185,7 @@ status = "draft — needs native review" "Bottom left radius" = "Rayon inférieur gauche" "Bottom right radius" = "Rayon inférieur droit" "Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable." +"Brainstorm" = "Remue-méninges" "Branch" = "Branche" "Branch archived" = "Branche archivée" "Branch merged" = "Branche fusionnée" @@ -209,6 +214,8 @@ status = "draft — needs native review" "Checking the server…" = "Vérification auprès du serveur…" "Chevron" = "Chevron" "Clear from this list" = "Effacer de cette liste" +"Clear results" = "Effacer les résultats" +"Click objects to vote; click your badge to take a vote back" = "Cliquez sur des objets pour voter ; cliquez sur votre badge pour retirer un vote" "Code or invite link" = "Code ou lien d’invitation" "Color profile: {name}" = "Profil colorimétrique : {name}" "CSS custom properties…" = "Propriétés personnalisées CSS…" @@ -462,6 +469,7 @@ status = "draft — needs native review" "Empty" = "Vide" "End" = "Fin" "End point" = "Point final" +"End vote" = "Terminer le vote" "Ends the session; everyone keeps their copy" = "Met fin à la session ; chacun conserve sa copie" "Enter" = "Entrée" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Saisissez l’adresse d’un figcraft-server et appuyez sur Parcourir pour voir les plugins qu’il propose." @@ -538,6 +546,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Passer le texte de l’autre côté" "Flip vertical" = "Retourner verticalement" "Flip width profile" = "Inverser le profil de largeur" +"Flow chart" = "Logigramme" "Flow starting point" = "Point de départ du flux" "Flow starting points" = "Points de départ de flux" "Flows" = "Flux" @@ -693,6 +702,7 @@ status = "draft — needs native review" "Jun" = "juin" "Just now" = "À l’instant" "Justify" = "Justifier" +"Kanban board" = "Tableau kanban" "Keep a measurement on the canvas for developers" = "Garder une mesure sur le canevas pour les développeurs" "Keep appearance" = "Conserver l’apparence" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Conserver l’apparence convertit chaque valeur de couleur en {profile} pour que les couleurs restent identiques. Conserver les valeurs laisse les nombres tels quels, si bien que les couleurs changent." @@ -792,6 +802,7 @@ status = "draft — needs native review" "Min H" = "H min." "Min W" = "L min." "Minimize" = "Réduire" +"Minutes" = "Minutes" "Mirror angle" = "Symétrie d’angle" "Mirror angle and length" = "Symétrie d’angle et de longueur" "Mirroring" = "Symétrie" @@ -887,6 +898,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Aucun style de texte pour l’instant. Cliquez sur + pour en créer un à partir du texte sélectionné." "No variables fit here." = "Aucune variable ne convient ici." "No variables yet. Create them in Local variables." = "Aucune variable pour l’instant. Créez-en dans Variables locales." +"No votes yet" = "Aucun vote pour l’instant" "Nobody else is here yet" = "Personne d’autre n’est encore là" "Noise" = "Bruit" "None" = "Aucun" @@ -976,6 +988,7 @@ status = "draft — needs native review" "Page deleted" = "Page supprimée" "Pages" = "Pages" "Pages added to the document" = "Pages ajoutées au document" +"Pause" = "Pause" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" "Pink" = "Rose" "Placed on the current page" = "Placé sur la page actuelle" @@ -1093,6 +1106,7 @@ status = "draft — needs native review" "Remove all" = "Tout retirer" "Remove annotation" = "Retirer l’annotation" "Remove auto layout" = "Retirer la mise en page auto" +"Remove column" = "Retirer la colonne" "Remove dev resource" = "Retirer la ressource de développement" "Remove export setting" = "Retirer le réglage d’exportation" "Remove from recents" = "Retirer des récents" @@ -1106,6 +1120,7 @@ status = "draft — needs native review" "Remove min and max height" = "Retirer la hauteur min. et max." "Remove min and max width" = "Retirer la largeur min. et max." "Remove regions" = "Retirer les régions" +"Remove row" = "Retirer la ligne" "Remove slot" = "Retirer l’emplacement" "Remove starting point" = "Retirer le point de départ" "Remove stop" = "Retirer l’arrêt" @@ -1159,6 +1174,9 @@ status = "draft — needs native review" "Restore main component" = "Restaurer le composant principal" "Restore this version" = "Restaurer cette version" "Restored “{name}”" = "« {name} » restaurée" +"Retrospective" = "Rétrospective" +"Results" = "Résultats" +"Resume" = "Reprendre" "Reverse the order" = "Inverser l’ordre" "Reversed triangle" = "Triangle inversé" "Review and merge" = "Relire et fusionner" @@ -1336,17 +1354,20 @@ status = "draft — needs native review" "Start point" = "Point de départ" "Start sharing" = "Commencer le partage" "Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne" +"Start vote" = "Lancer le vote" "Starting to share…" = "Démarrage du partage…" "Status" = "Statut" "Sticky" = "Collant" "Sticky note" = "Note autocollante" "Sticky note colors" = "Couleurs des notes autocollantes" +"Sticky notes" = "Notes autocollantes" "Sticky notes, shapes and connectors" = "Notes autocollantes, formes et connecteurs" "Stop" = "Arrêter" "Stop following {name}" = "Ne plus suivre {name}" "Stop session" = "Arrêter la session" "Stop sharing" = "Arrêter le partage" "Stop spotlighting" = "Arrêter de faire suivre ma vue" +"Stop timer" = "Arrêter le minuteur" "Stop {n} {hex}" = "Arrêt {n} {hex}" "Straight" = "Droit" "Stretch" = "Étirer" @@ -1434,6 +1455,7 @@ status = "draft — needs native review" "Tidy up" = "Ranger" "Tile" = "Mosaïque" "Tile type" = "Type de mosaïque" +"Timer" = "Minuteur" "Tint" = "Nuance" "Title" = "Titre" "Title case" = "Casse de titre" @@ -1529,6 +1551,8 @@ status = "draft — needs native review" "Visibility" = "Visibilité" "Visible" = "Visible" "Vivid" = "Vif" +"Votes per person" = "Votes par personne" +"Voting session" = "Session de vote" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND est définie et impose l’API graphique ; sans elle, FigCraft choisit lui-même." "Warm" = "Chaud" "Watch" = "Montre" @@ -1538,6 +1562,7 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL WebSocket copiée" "Websites" = "Sites web" "Wedge" = "Coin" +"Weekly planner" = "Planning de la semaine" "Weight" = "Graisse" "What changed since a saved version" = "Ce qui a changé depuis une version enregistrée" "What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev" @@ -1748,6 +1773,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} calque sélectionné", other = "{n} calques sélectionnés" } "{n} layers · {parts}" = { one = "{n} calque · {parts}", other = "{n} calques · {parts}" } "{n} matches" = { one = "{n} résultat", other = "{n} résultats" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "il y a {n} minute", other = "il y a {n} minutes" } "{n} months ago" = { one = "il y a {n} mois", other = "il y a {n} mois" } "{n} more notes omitted from this report" = { one = "{n} autre note omise de ce rapport", other = "{n} autres notes omises de ce rapport" } @@ -1760,6 +1786,8 @@ status = "draft — needs native review" "{n} rows" = { one = "{n} ligne", other = "{n} lignes" } "{n} styles" = { one = "{n} style", other = "{n} styles" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } +"{n} votes" = { one = "{n} vote", other = "{n} votes" } +"{n} votes left" = { one = "{n} vote restant", other = "{n} votes restants" } "{n} weeks ago" = { one = "il y a {n} semaine", other = "il y a {n} semaines" } "{n} years ago" = { one = "il y a {n} an", other = "il y a {n} ans" } "{n}d" = "{n} j" @@ -1823,6 +1851,14 @@ status = "draft — needs native review" "board.shape" = "Forme avec texte" "board.stamp" = "Tampon" "board.sticky" = "Note autocollante" +"board.table" = "Tableau" +"board.tableEdit" = "Modifier le tableau" +"board.template" = "Modèle de tableau blanc" +"board.templates" = "Lister les modèles de tableau blanc" +"board.timer" = "Minuteur" +"board.timerState" = "État du minuteur" +"board.vote" = "Session de vote" +"board.voteResults" = "Résultats du vote" "bool.exclude" = "Exclure la sélection" "bool.flatten" = "Aplatir le booléen" "bool.intersect" = "Intersection de la sélection" @@ -2380,6 +2416,8 @@ status = "draft — needs native review" "widget.click" = "Cliquer sur le widget" "widget.insert" = "Insérer un widget" "widget.list" = "Lister les widgets" +"widget.menu" = "Menu du widget" +"widget.setText" = "Modifier le champ du widget" "window.focus" = "Mettre la fenêtre au premier plan" "window.new" = "Nouvelle fenêtre" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index d007cd3b..aee866b1 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -37,6 +37,7 @@ status = "draft — needs native review" "Add a description" = "Tambah deskripsi" "Add a frame (F) or a shape (R) to get started" = "Tambahkan frame (F) atau bentuk (R) untuk memulai" "Add a layer or component to tile first" = "Tambahkan dulu layer atau component untuk dijadikan ubin" +"Add a minute" = "Tambah satu menit" "Add a note for developers" = "Tambah catatan untuk developer" "Add action" = "Tambah aksi" "Add actions for when no condition holds" = "Tambah aksi saat tidak ada kondisi yang terpenuhi" @@ -44,6 +45,7 @@ status = "draft — needs native review" "Add another condition" = "Tambah kondisi lain" "Add auto layout" = "Tambah auto layout" "Add background behind overlay" = "Tambah latar belakang di balik overlay" +"Add column" = "Tambah kolom" "Add content to this slot" = "Tambah konten ke slot ini" "Add dev resource" = "Tambah sumber daya dev" "Add effect" = "Tambah efek" @@ -63,6 +65,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Tambah atau perbarui koleksi dari JSON variabel" "Add properties to make instances configurable" = "Tambahkan properti agar instance dapat dikonfigurasi" "Add reaction" = "Tambah reaksi" +"Add row" = "Tambah baris" "Add starting point" = "Tambah titik awal" "Add stroke" = "Tambah stroke" "Add variant" = "Tambah variant" @@ -91,6 +94,7 @@ status = "draft — needs native review" "All comments" = "Semua komentar" "All components" = "Semua component" "All fonts" = "Semua font" +"All objects" = "Semua objek" "All supported properties" = "Semua properti yang didukung" "All variables" = "Semua variabel" "Along the bottom" = "Sepanjang sisi bawah" @@ -179,6 +183,7 @@ status = "draft — needs native review" "Bottom" = "Bawah" "Bottom left radius" = "Radius kiri bawah" "Bottom right radius" = "Radius kanan bawah" +"Brainstorm" = "Curah gagasan" "Branch" = "Branch" "Branch archived" = "Branch diarsipkan" "Branch merged" = "Branch di-merge" @@ -208,6 +213,8 @@ status = "draft — needs native review" "Checking the server…" = "Memeriksa server…" "Chevron" = "Chevron" "Clear from this list" = "Hapus dari daftar ini" +"Clear results" = "Hapus hasil" +"Click objects to vote; click your badge to take a vote back" = "Klik objek untuk memberi suara; klik lencana Anda untuk menarik suara" "Code or invite link" = "Kode atau link undangan" "Color profile: {name}" = "Profil warna: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Gaya warna dan teks, variabel Light dan Dark, tombol, input, dan kartu" @@ -452,6 +459,7 @@ status = "draft — needs native review" "Empty" = "Kosong" "End" = "Akhir" "End point" = "Titik akhir" +"End vote" = "Akhiri pemungutan suara" "Ends the session; everyone keeps their copy" = "Mengakhiri sesi; semua orang tetap memiliki salinannya" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Masukkan alamat figcraft-server lalu tekan Jelajahi untuk melihat plugin yang ditawarkannya." @@ -527,6 +535,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Balik teks ke sisi lain" "Flip vertical" = "Balik vertikal" "Flip width profile" = "Balik profil lebar" +"Flow chart" = "Diagram alir" "Flow starting point" = "Titik awal alur" "Flow starting points" = "Titik awal alur" "Flows" = "Alur" @@ -680,6 +689,7 @@ status = "draft — needs native review" "Jun" = "Jun" "Just now" = "Baru saja" "Justify" = "Rata kiri-kanan" +"Kanban board" = "Papan kanban" "Keep a measurement on the canvas for developers" = "Simpan pengukuran di kanvas untuk developer" "Keep appearance" = "Pertahankan tampilan" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Pertahankan tampilan mengonversi setiap nilai warna ke {profile} agar warna terlihat sama. Pertahankan nilai membiarkan angkanya apa adanya, sehingga warna bergeser." @@ -779,6 +789,7 @@ status = "draft — needs native review" "Min H" = "Min T" "Min W" = "Min L" "Minimize" = "Minimalkan" +"Minutes" = "Menit" "Mirror angle" = "Cerminkan sudut" "Mirror angle and length" = "Cerminkan sudut dan panjang" "Mirroring" = "Pencerminan" @@ -873,6 +884,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Belum ada gaya teks. Klik + untuk membuat dari teks yang dipilih." "No variables fit here." = "Tidak ada variabel yang sesuai di sini." "No variables yet. Create them in Local variables." = "Belum ada variabel. Buat di Variabel lokal." +"No votes yet" = "Belum ada suara" "Nobody else is here yet" = "Belum ada orang lain di sini" "Noise" = "Noise" "None" = "Tidak ada" @@ -958,6 +970,7 @@ status = "draft — needs native review" "Page deleted" = "Halaman dihapus" "Pages" = "Halaman" "Pages added to the document" = "Halaman ditambahkan ke dokumen" +"Pause" = "Jeda" "Pink" = "Merah muda" "Placed on the current page" = "Ditempatkan di halaman saat ini" "Pages: {pages}" = "Halaman: {pages}" @@ -1072,6 +1085,7 @@ status = "draft — needs native review" "Remove all" = "Hapus semua" "Remove annotation" = "Hapus anotasi" "Remove auto layout" = "Hapus auto layout" +"Remove column" = "Hapus kolom" "Remove dev resource" = "Hapus sumber daya dev" "Remove export setting" = "Hapus pengaturan ekspor" "Remove from recents" = "Hapus dari terbaru" @@ -1085,6 +1099,7 @@ status = "draft — needs native review" "Remove min and max height" = "Hapus tinggi min dan maks" "Remove min and max width" = "Hapus lebar min dan maks" "Remove regions" = "Hapus area" +"Remove row" = "Hapus baris" "Remove slot" = "Hapus slot" "Remove starting point" = "Hapus titik awal" "Remove stop" = "Hapus stop" @@ -1136,6 +1151,9 @@ status = "draft — needs native review" "Restore main component" = "Pulihkan main component" "Restore this version" = "Pulihkan versi ini" "Restored “{name}”" = "“{name}” dipulihkan" +"Retrospective" = "Retrospektif" +"Results" = "Hasil" +"Resume" = "Lanjutkan" "Reverse the order" = "Balik urutan" "Reversed triangle" = "Segitiga terbalik" "Review and merge" = "Tinjau dan merge" @@ -1311,17 +1329,20 @@ status = "draft — needs native review" "Start point" = "Titik awal" "Start sharing" = "Mulai berbagi" "Start sharing with the Share button, then pass on the code it gives" = "Mulai berbagi dengan tombol Bagikan, lalu teruskan kode yang diberikannya" +"Start vote" = "Mulai pemungutan suara" "Starting to share…" = "Memulai berbagi…" "Status" = "Status" "Sticky" = "Menempel" "Sticky note" = "Catatan tempel" "Sticky note colors" = "Warna catatan tempel" +"Sticky notes" = "Catatan tempel" "Sticky notes, shapes and connectors" = "Catatan tempel, bentuk, dan konektor" "Stop" = "Hentikan" "Stop following {name}" = "Berhenti mengikuti {name}" "Stop session" = "Hentikan sesi" "Stop sharing" = "Berhenti berbagi" "Stop spotlighting" = "Hentikan sorotan" +"Stop timer" = "Hentikan pengatur waktu" "Stop {n} {hex}" = "Titik {n} {hex}" "Straight" = "Lurus" "Stretch" = "Regangkan" @@ -1403,6 +1424,7 @@ status = "draft — needs native review" "Tidy up" = "Rapikan" "Tile" = "Ubin" "Tile type" = "Jenis ubin" +"Timer" = "Pengatur waktu" "Tint" = "Rona" "Title" = "Judul" "Title case" = "Huruf Awal Kapital" @@ -1495,6 +1517,8 @@ status = "draft — needs native review" "Visibility" = "Visibilitas" "Visible" = "Terlihat" "Vivid" = "Cerah" +"Votes per person" = "Suara per orang" +"Voting session" = "Sesi pemungutan suara" "Warm" = "Hangat" "Watch" = "Jam tangan" "Wavy" = "Bergelombang" @@ -1503,6 +1527,7 @@ status = "draft — needs native review" "WebSocket URL" = "URL WebSocket" "WebSocket URL copied" = "URL WebSocket disalin" "Wedge" = "Baji" +"Weekly planner" = "Perencana mingguan" "Weight" = "Ketebalan" "What you try here changes this preview and the code below, not the file" = "Yang Anda coba di sini mengubah pratinjau ini dan kode di bawah, bukan file-nya" "What changed since it was marked ready for dev" = "Yang berubah sejak ditandai siap untuk dev" @@ -1710,6 +1735,7 @@ status = "draft — needs native review" "{n} layers selected" = { other = "{n} layer dipilih" } "{n} layers · {parts}" = { other = "{n} layer · {parts}" } "{n} matches" = { other = "{n} kecocokan" } +"{n} min" = "{n} mnt" "{n} minutes ago" = { other = "{n} menit lalu" } "{n} months ago" = { other = "{n} bulan lalu" } "{n} more notes omitted from this report" = { other = "{n} catatan lainnya tidak dicantumkan dalam laporan ini" } @@ -1722,6 +1748,8 @@ status = "draft — needs native review" "{n} rows" = { other = "{n} baris" } "{n} styles" = { other = "{n} gaya" } "{n} variables" = { other = "{n} variabel" } +"{n} votes" = { other = "{n} suara" } +"{n} votes left" = { other = "Sisa {n} suara" } "{n} weeks ago" = { other = "{n} minggu lalu" } "{n} years ago" = { other = "{n} tahun lalu" } "{n}d" = "{n} hr" @@ -1784,6 +1812,14 @@ status = "draft — needs native review" "board.shape" = "Bentuk dengan teks" "board.stamp" = "Stempel" "board.sticky" = "Catatan tempel" +"board.table" = "Tabel" +"board.tableEdit" = "Edit tabel" +"board.template" = "Templat papan" +"board.templates" = "Daftar templat papan" +"board.timer" = "Pengatur waktu" +"board.timerState" = "Status pengatur waktu" +"board.vote" = "Sesi pemungutan suara" +"board.voteResults" = "Hasil pemungutan suara" "bool.exclude" = "Kecualikan pilihan" "bool.flatten" = "Satukan boolean" "bool.intersect" = "Iriskan pilihan" @@ -2335,6 +2371,8 @@ status = "draft — needs native review" "widget.click" = "Klik widget" "widget.insert" = "Sisipkan widget" "widget.list" = "Daftar widget" +"widget.menu" = "Menu widget" +"widget.setText" = "Edit kolom widget" "window.focus" = "Bawa jendela ke depan" "window.new" = "Jendela baru" "template.list" = "Daftar templat" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index dc0a0e3b..30650bbc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -39,6 +39,7 @@ status = "draft — needs native review" "Add a description" = "説明を追加" "Add a frame (F) or a shape (R) to get started" = "フレーム(F)またはシェイプ(R)を追加して開始" "Add a layer or component to tile first" = "先に並べるレイヤーまたはコンポーネントを追加" +"Add a minute" = "1分追加" "Add a note for developers" = "開発者向けのメモを追加" "Add action" = "アクションを追加" "Add actions for when no condition holds" = "どの条件にも当てはまらないときのアクションを追加" @@ -46,6 +47,7 @@ status = "draft — needs native review" "Add another condition" = "条件を追加" "Add auto layout" = "オートレイアウトを追加" "Add background behind overlay" = "オーバーレイの背後に背景を追加" +"Add column" = "列を追加" "Add content to this slot" = "このスロットにコンテンツを追加" "Add dev resource" = "開発リソースを追加" "Add effect" = "エフェクトを追加" @@ -65,6 +67,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "変数 JSON からコレクションを追加・更新" "Add properties to make instances configurable" = "プロパティを追加してインスタンスを設定可能にする" "Add reaction" = "リアクションを追加" +"Add row" = "行を追加" "Add starting point" = "開始点を追加" "Add stroke" = "線を追加" "Add variant" = "バリアントを追加" @@ -93,6 +96,7 @@ status = "draft — needs native review" "All comments" = "すべてのコメント" "All components" = "すべてのコンポーネント" "All fonts" = "すべてのフォント" +"All objects" = "すべてのオブジェクト" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" "Along the bottom" = "下辺に沿って" @@ -182,6 +186,7 @@ status = "draft — needs native review" "Bottom left radius" = "左下の半径" "Bottom right radius" = "右下の半径" "Bound to {name}. Click to detach the variable." = "「{name}」を適用中。クリックで変数の割り当てを解除。" +"Brainstorm" = "ブレインストーミング" "Branch" = "ブランチ" "Branch archived" = "ブランチをアーカイブ済み" "Branch merged" = "ブランチをマージ済み" @@ -210,6 +215,8 @@ status = "draft — needs native review" "Checking the server…" = "サーバーを確認中…" "Chevron" = "シェブロン" "Clear from this list" = "このリストから消去" +"Clear results" = "結果をクリア" +"Click objects to vote; click your badge to take a vote back" = "オブジェクトをクリックして投票、自分のバッジをクリックして票を取り消し" "Code or invite link" = "コードまたは招待リンク" "Color profile: {name}" = "カラープロファイル:{name}" "CSS custom properties…" = "CSS カスタムプロパティ…" @@ -457,6 +464,7 @@ status = "draft — needs native review" "Empty" = "空" "End" = "終了" "End point" = "終点" +"End vote" = "投票を終了" "Ends the session; everyone keeps their copy" = "セッションを終了。各自のコピーは残る" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "figcraft-server のアドレスを入力し、「参照」を押すと、提供されているプラグインが表示される。" @@ -533,6 +541,7 @@ status = "draft — needs native review" "Flip text to the other side" = "テキストを反対側に反転" "Flip vertical" = "上下反転" "Flip width profile" = "幅プロファイルを反転" +"Flow chart" = "フローチャート" "Flow starting point" = "フローの開始点" "Flow starting points" = "フローの開始点" "Flows" = "フロー" @@ -687,6 +696,7 @@ status = "draft — needs native review" "Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" +"Kanban board" = "カンバンボード" "Keep a measurement on the canvas for developers" = "開発者向けに測定をキャンバスに残す" "Keep appearance" = "見た目を保持" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "「見た目を保持」はすべてのカラー値を {profile} に変換し、色の見た目を保つ。「値を保持」は数値をそのまま残すため、色が変わる。" @@ -786,6 +796,7 @@ status = "draft — needs native review" "Min H" = "最小 H" "Min W" = "最小 W" "Minimize" = "最小化" +"Minutes" = "分" "Mirror angle" = "角度を対称にする" "Mirror angle and length" = "角度と長さを対称にする" "Mirroring" = "対称" @@ -880,6 +891,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "テキストスタイルはまだない。+ をクリックして選択したテキストから作成。" "No variables fit here." = "ここに使える変数はない。" "No variables yet. Create them in Local variables." = "変数はまだない。ローカル変数で作成。" +"No votes yet" = "まだ投票はありません" "Nobody else is here yet" = "まだ他に誰もいない" "Noise" = "ノイズ" "None" = "なし" @@ -966,6 +978,7 @@ status = "draft — needs native review" "Page deleted" = "ページは削除済み" "Pages" = "ページ" "Pages added to the document" = "ページをドキュメントに追加した" +"Pause" = "一時停止" "Pink" = "ピンク" "Placed on the current page" = "現在のページに配置した" "Pages: {pages}" = "ページ:{pages}" @@ -1080,6 +1093,7 @@ status = "draft — needs native review" "Remove all" = "すべて削除" "Remove annotation" = "注釈を削除" "Remove auto layout" = "オートレイアウトを解除" +"Remove column" = "列を削除" "Remove dev resource" = "開発リソースを削除" "Remove export setting" = "書き出し設定を削除" "Remove from recents" = "最近使ったファイルから削除" @@ -1093,6 +1107,7 @@ status = "draft — needs native review" "Remove min and max height" = "最小と最大の高さを削除" "Remove min and max width" = "最小と最大の幅を削除" "Remove regions" = "領域を削除" +"Remove row" = "行を削除" "Remove slot" = "スロットを削除" "Remove starting point" = "開始点を削除" "Remove stop" = "カラー分岐点を削除" @@ -1146,6 +1161,9 @@ status = "draft — needs native review" "Restore main component" = "メインコンポーネントを復元" "Restore this version" = "このバージョンを復元" "Restored “{name}”" = "「{name}」を復元済み" +"Retrospective" = "振り返り" +"Results" = "結果" +"Resume" = "再開" "Reverse the order" = "順序を反転" "Reversed triangle" = "逆三角形" "Review and merge" = "レビューしてマージ" @@ -1323,17 +1341,20 @@ status = "draft — needs native review" "Start point" = "始点" "Start sharing" = "共有を開始" "Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" +"Start vote" = "投票を開始" "Starting to share…" = "共有を開始中…" "Status" = "ステータス" "Sticky" = "固定" "Sticky note" = "付箋" "Sticky note colors" = "付箋の色" +"Sticky notes" = "付箋" "Sticky notes, shapes and connectors" = "付箋、シェイプ、コネクタ" "Stop" = "停止" "Stop following {name}" = "{name} のフォローを停止" "Stop session" = "セッションを停止" "Stop sharing" = "共有を停止" "Stop spotlighting" = "スポットライトを停止" +"Stop timer" = "タイマーを停止" "Stop {n} {hex}" = "分岐点 {n} {hex}" "Straight" = "直線" "Stretch" = "伸縮" @@ -1417,6 +1438,7 @@ status = "draft — needs native review" "Tidy up" = "整列" "Tile" = "タイル" "Tile type" = "タイルの種類" +"Timer" = "タイマー" "Tint" = "ティント" "Title" = "タイトル" "Title case" = "タイトルケース" @@ -1511,6 +1533,8 @@ status = "draft — needs native review" "Visibility" = "表示" "Visible" = "表示" "Vivid" = "ビビッド" +"Votes per person" = "1人あたりの票数" +"Voting session" = "投票セッション" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND が設定されていて、使うグラフィックス API を指定している。解除すると FigCraft が選ぶ。" "Warm" = "ウォーム" "Watch" = "ウォッチ" @@ -1520,6 +1544,7 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket URL をコピー済み" "Websites" = "Webサイト" "Wedge" = "くさび" +"Weekly planner" = "週間プランナー" "Weight" = "太さ" "What changed since a saved version" = "保存したバージョンからの変更" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" @@ -1729,6 +1754,7 @@ status = "draft — needs native review" "{n} layers selected" = { other = "{n} 個のレイヤーを選択中" } "{n} layers · {parts}" = { other = "{n} 個のレイヤー · {parts}" } "{n} matches" = { other = "{n} 件一致" } +"{n} min" = "{n}分" "{n} minutes ago" = { other = "{n} 分前" } "{n} months ago" = { other = "{n} か月前" } "{n} more notes omitted from this report" = { other = "このレポートで省略した注記がさらに {n} 件" } @@ -1741,6 +1767,8 @@ status = "draft — needs native review" "{n} rows" = { other = "{n} 行" } "{n} styles" = { other = "{n} 個のスタイル" } "{n} variables" = { other = "{n} 個の変数" } +"{n} votes" = { other = "{n}票" } +"{n} votes left" = { other = "残り{n}票" } "{n} weeks ago" = { other = "{n} 週間前" } "{n} years ago" = { other = "{n} 年前" } "{n}d" = "{n}日" @@ -1803,6 +1831,14 @@ status = "draft — needs native review" "board.shape" = "テキスト付きシェイプ" "board.stamp" = "スタンプ" "board.sticky" = "付箋" +"board.table" = "表" +"board.tableEdit" = "表を編集" +"board.template" = "ボードテンプレート" +"board.templates" = "ボードテンプレートの一覧" +"board.timer" = "タイマー" +"board.timerState" = "タイマーの状態" +"board.vote" = "投票セッション" +"board.voteResults" = "投票結果" "bool.exclude" = "選択範囲を除外" "bool.flatten" = "ブーリアンを平坦化" "bool.intersect" = "選択範囲を交差" @@ -2358,6 +2394,8 @@ status = "draft — needs native review" "widget.click" = "ウィジェットをクリック" "widget.insert" = "ウィジェットを挿入" "widget.list" = "ウィジェットを一覧表示" +"widget.menu" = "ウィジェットのメニュー" +"widget.setText" = "ウィジェットのフィールドを編集" "window.focus" = "ウィンドウを最前面に表示" "window.new" = "新しいウィンドウ" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 0cab4030..8b86338a 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -39,6 +39,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add a description" = "აღწერის დამატება" "Add a frame (F) or a shape (R) to get started" = "დასაწყებად დაამატეთ ჩარჩო (F) ან ფიგურა (R)" "Add a layer or component to tile first" = "ჯერ დაამატეთ შრე ან კომპონენტი ფილებად დასაწყობად" +"Add a minute" = "ერთი წუთის დამატება" "Add a note for developers" = "შენიშვნის დამატება დეველოპერებისთვის" "Add action" = "მოქმედების დამატება" "Add actions for when no condition holds" = "მოქმედებების დამატება იმ შემთხვევისთვის, როცა არცერთი პირობა არ სრულდება" @@ -46,6 +47,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add another condition" = "კიდევ ერთი პირობის დამატება" "Add auto layout" = "ავტოგანლაგების დამატება" "Add background behind overlay" = "ფონის დამატება გადაფარვის უკან" +"Add column" = "სვეტის დამატება" "Add content to this slot" = "შიგთავსის დამატება ამ სლოტში" "Add dev resource" = "დეველოპერის რესურსის დამატება" "Add effect" = "ეფექტის დამატება" @@ -65,6 +67,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add or update collections from a variables JSON" = "კოლექციების დამატება ან განახლება ცვლადების JSON-იდან" "Add properties to make instances configurable" = "დაამატეთ თვისებები, რომ ინსტანსები კონფიგურირებადი გახდეს" "Add reaction" = "რეაქციის დამატება" +"Add row" = "მწკრივის დამატება" "Add starting point" = "საწყისი წერტილის დამატება" "Add stroke" = "კონტურის დამატება" "Add variant" = "ვარიანტის დამატება" @@ -94,6 +97,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "All comments" = "ყველა კომენტარი" "All components" = "ყველა კომპონენტი" "All fonts" = "ყველა შრიფტი" +"All objects" = "ყველა ობიექტი" "All supported properties" = "ყველა მხარდაჭერილი თვისება" "All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" "All variables" = "ყველა ცვლადი" @@ -183,6 +187,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Bottom" = "ქვემოთ" "Bottom left radius" = "ქვედა მარცხენა რადიუსი" "Bottom right radius" = "ქვედა მარჯვენა რადიუსი" +"Brainstorm" = "გონებრივი იერიში" "Branch" = "განშტოება" "Branch archived" = "განშტოება დაარქივებულია" "Branch merged" = "განშტოება შერწყმულია" @@ -213,6 +218,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Checking the server…" = "სერვერის შემოწმება…" "Chevron" = "შევრონი" "Clear from this list" = "ამ სიიდან ამოღება" +"Clear results" = "შედეგების გასუფთავება" +"Click objects to vote; click your badge to take a vote back" = "ხმის მისაცემად დააწკაპუნეთ ობიექტებზე; ხმის დასაბრუნებლად დააწკაპუნეთ თქვენს ნიშანზე" "Code or invite link" = "კოდი ან მოსაწვევი ბმული" "Color profile: {name}" = "ფერის პროფილი: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები" @@ -463,6 +470,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Empty" = "ცარიელი" "End" = "ბოლო" "End point" = "საბოლოო წერტილი" +"End vote" = "ხმის მიცემის დასრულება" "Ends the session; everyone keeps their copy" = "ასრულებს სესიას; ყველას თავისი ასლი რჩება" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "შეიყვანეთ figcraft-server-ის მისამართი და დააჭირეთ „დათვალიერებას“, რომ ნახოთ მის მიერ შემოთავაზებული პლაგინები." @@ -539,6 +547,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Flip text to the other side" = "ტექსტის მეორე მხარეს გადატანა" "Flip vertical" = "ვერტიკალურად არეკვლა" "Flip width profile" = "სიგანის პროფილის არეკვლა" +"Flow chart" = "ბლოკ-სქემა" "Flow starting point" = "ნაკადის საწყისი წერტილი" "Flow starting points" = "ნაკადის საწყისი წერტილები" "Flows" = "ნაკადები" @@ -693,6 +702,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Jun" = "ივნ" "Just now" = "ახლახან" "Justify" = "ორმხრივი სწორება" +"Kanban board" = "კანბან დაფა" "Keep a measurement on the canvas for developers" = "გაზომვის შენახვა ტილოზე დეველოპერებისთვის" "Keep appearance" = "იერსახის შენარჩუნება" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„იერსახის შენარჩუნება“ ყველა ფერის მნიშვნელობას {profile}-ში გარდაქმნის, რომ ფერები იგივენაირად გამოჩნდეს. „მნიშვნელობების შენარჩუნება“ რიცხვებს უცვლელად ტოვებს, ამიტომ ფერები იცვლება." @@ -792,6 +802,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Min H" = "მინ. H" "Min W" = "მინ. W" "Minimize" = "ჩაკეცვა" +"Minutes" = "წუთები" "Mirror angle" = "კუთხის არეკვლა" "Mirror angle and length" = "კუთხისა და სიგრძის არეკვლა" "Mirroring" = "არეკვლა" @@ -889,6 +900,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "No text styles yet. Click + to create one from the selected text." = "ტექსტის სტილები ჯერ არ არის. დაწკაპეთ +, რომ მონიშნული ტექსტიდან შექმნათ." "No variables fit here." = "აქ არცერთი ცვლადი არ ერგება." "No variables yet. Create them in Local variables." = "ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No votes yet" = "ხმები ჯერ არ არის" "Nobody else is here yet" = "აქ ჯერ სხვა არავინ არის" "Noise" = "ხმაური" "None" = "არცერთი" @@ -975,6 +987,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Page" = "გვერდი" "Pages" = "გვერდები" "Pages added to the document" = "გვერდები დაემატა დოკუმენტს" +"Pause" = "პაუზა" "Pink" = "ვარდისფერი" "Placed on the current page" = "განთავსდა მიმდინარე გვერდზე" "Pages: {pages}" = "გვერდი: {pages}" @@ -1091,6 +1104,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Remove all" = "ყველას წაშლა" "Remove annotation" = "ანოტაციის წაშლა" "Remove auto layout" = "ავტოგანლაგების მოხსნა" +"Remove column" = "სვეტის წაშლა" "Remove dev resource" = "დეველოპერის რესურსის წაშლა" "Remove export setting" = "ექსპორტის პარამეტრის წაშლა" "Remove from recents" = "ბოლოდროინდელებიდან ამოშლა" @@ -1104,6 +1118,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Remove min and max height" = "მინ. და მაქს. სიმაღლის მოხსნა" "Remove min and max width" = "მინ. და მაქს. სიგანის მოხსნა" "Remove regions" = "არეების წაშლა" +"Remove row" = "მწკრივის წაშლა" "Remove slot" = "სლოტის მოხსნა" "Remove starting point" = "საწყისი წერტილის წაშლა" "Remove stop" = "გადასვლის წერტილის წაშლა" @@ -1156,6 +1171,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Restore main component" = "მთავარი კომპონენტის აღდგენა" "Restore this version" = "ამ ვერსიის აღდგენა" "Restored “{name}”" = "„{name}“ აღდგა" +"Retrospective" = "რეტროსპექტივა" +"Results" = "შედეგები" +"Resume" = "გაგრძელება" "Reverse the order" = "რიგის შებრუნება" "Reversed triangle" = "შებრუნებული სამკუთხედი" "Review and merge" = "განხილვა და შერწყმა" @@ -1334,17 +1352,20 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Start point" = "საწყისი წერტილი" "Start sharing" = "გაზიარების დაწყება" "Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი" +"Start vote" = "ხმის მიცემის დაწყება" "Starting to share…" = "გაზიარების დაწყება…" "Status" = "სტატუსი" "Sticky" = "მიმაგრებული" "Sticky note" = "სტიკერი" "Sticky note colors" = "სტიკერების ფერები" +"Sticky notes" = "სტიკერები" "Sticky notes, shapes and connectors" = "სტიკერები, ფიგურები და შემაერთებლები" "Stop" = "შეწყვეტა" "Stop following {name}" = "{name}-ის მიყოლის შეწყვეტა" "Stop session" = "სესიის შეწყვეტა" "Stop sharing" = "გაზიარების შეწყვეტა" "Stop spotlighting" = "ჩემი ხედის ჩვენების შეწყვეტა" +"Stop timer" = "ტაიმერის გაჩერება" "Stop {n} {hex}" = "გადასვლის წერტილი {n} {hex}" "Straight" = "სწორი" "Stretch" = "გაწელვა" @@ -1430,6 +1451,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Tidy up" = "მოწესრიგება" "Tile" = "ფილებად" "Tile type" = "ფილის ტიპი" +"Timer" = "ტაიმერი" "Tint" = "ტონირება" "Title" = "დასახელება" "Title case" = "სათაურის რეგისტრი" @@ -1525,6 +1547,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Visibility" = "ხილვადობა" "Visible" = "ხილული" "Vivid" = "ცოცხალი" +"Votes per person" = "ხმები ერთ ადამიანზე" +"Voting session" = "ხმის მიცემის სესია" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND დაყენებულია და გამოსაყენებელ გრაფიკულ API-ს განსაზღვრავს; მისი მოხსნის შემდეგ FigCraft თავად აირჩევს." "Warm" = "თბილი" "Watch" = "საათი" @@ -1534,6 +1558,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL დაკოპირდა" "Wedge" = "სოლი" +"Weekly planner" = "კვირის გეგმა" "Weight" = "სისქე" "What you try here changes this preview and the code below, not the file" = "რასაც აქ ცდით, ცვლის ამ გადახედვას და ქვემოთ კოდს და არა ფაილს" "What changed since it was marked ready for dev" = "რა შეიცვალა მზად დეველოპერისთვის მონიშვნის შემდეგ" @@ -1743,6 +1768,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "{n} layers selected" = { one = "მონიშნული შრე: {n}", other = "მონიშნული შრე: {n}" } "{n} layers · {parts}" = { one = "შრე: {n} · {parts}", other = "შრე: {n} · {parts}" } "{n} matches" = { one = "დამთხვევა: {n}", other = "დამთხვევა: {n}" } +"{n} min" = "{n} წთ" "{n} minutes ago" = { one = "{n} წუთის წინ", other = "{n} წუთის წინ" } "{n} months ago" = { one = "{n} თვის წინ", other = "{n} თვის წინ" } "{n} more notes omitted from this report" = { one = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}", other = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}" } @@ -1756,6 +1782,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "{n} styles" = { one = "სტილი: {n}", other = "სტილი: {n}" } "{n} text layers" = { one = "ტექსტის შრე: {n}", other = "ტექსტის შრე: {n}" } "{n} variables" = { one = "ცვლადი: {n}", other = "ცვლადი: {n}" } +"{n} votes" = { one = "ხმა: {n}", other = "ხმა: {n}" } +"{n} votes left" = { one = "დარჩენილი ხმა: {n}", other = "დარჩენილი ხმა: {n}" } "{n} weeks ago" = { one = "{n} კვირის წინ", other = "{n} კვირის წინ" } "{n} years ago" = { one = "{n} წლის წინ", other = "{n} წლის წინ" } "{n}d" = "{n} დღ" @@ -1818,6 +1846,14 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "board.shape" = "ფიგურა ტექსტით" "board.stamp" = "შტამპი" "board.sticky" = "სტიკერი" +"board.table" = "ცხრილი" +"board.tableEdit" = "ცხრილის რედაქტირება" +"board.template" = "დაფის შაბლონი" +"board.templates" = "დაფის შაბლონების სია" +"board.timer" = "ტაიმერი" +"board.timerState" = "ტაიმერის მდგომარეობა" +"board.vote" = "ხმის მიცემის სესია" +"board.voteResults" = "ხმის მიცემის შედეგები" "bool.exclude" = "მონიშნულის გამორიცხვა" "bool.flatten" = "ლოგიკური ოპერაციის გაბრტყელება" "bool.intersect" = "მონიშნულის გადაკვეთა" @@ -2371,6 +2407,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "widget.click" = "ვიჯეტზე დაწკაპუნება" "widget.insert" = "ვიჯეტის ჩასმა" "widget.list" = "ვიჯეტების სია" +"widget.menu" = "ვიჯეტის მენიუ" +"widget.setText" = "ვიჯეტის ველის რედაქტირება" "window.focus" = "ფანჯრის წინა პლანზე გადმოტანა" "window.new" = "ახალი ფანჯარა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index e2c88d32..5a976f97 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Add a description" = "설명 추가" "Add a frame (F) or a shape (R) to get started" = "프레임(F) 또는 도형(R)을 추가해 시작하세요" "Add a layer or component to tile first" = "먼저 바둑판식으로 배열할 레이어나 컴포넌트를 추가하세요" +"Add a minute" = "1분 추가" "Add a note for developers" = "개발자용 메모 추가" "Add action" = "액션 추가" "Add actions for when no condition holds" = "조건에 맞지 않을 때의 액션 추가" @@ -42,6 +43,7 @@ status = "draft — needs native review" "Add another condition" = "조건 추가" "Add auto layout" = "자동 레이아웃 추가" "Add background behind overlay" = "오버레이 뒤에 배경 추가" +"Add column" = "열 추가" "Add content to this slot" = "이 슬롯에 콘텐츠 추가" "Add dev resource" = "개발 리소스 추가" "Add effect" = "효과 추가" @@ -61,6 +63,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "변수 JSON에서 컬렉션 추가 또는 업데이트" "Add properties to make instances configurable" = "속성을 추가해 인스턴스를 설정 가능하게 만들기" "Add reaction" = "반응 추가" +"Add row" = "행 추가" "Add starting point" = "시작점 추가" "Add stroke" = "선 추가" "Add variant" = "배리언트 추가" @@ -89,6 +92,7 @@ status = "draft — needs native review" "All comments" = "모든 댓글" "All components" = "모든 컴포넌트" "All fonts" = "모든 글꼴" +"All objects" = "모든 개체" "All supported properties" = "지원되는 모든 속성" "All variables" = "모든 변수" "Along the bottom" = "아래쪽 가장자리를 따라" @@ -172,6 +176,7 @@ status = "draft — needs native review" "Bottom" = "아래쪽" "Bottom left radius" = "왼쪽 아래 반경" "Bottom right radius" = "오른쪽 아래 반경" +"Brainstorm" = "브레인스토밍" "Branch" = "브랜치" "Branch archived" = "브랜치 보관됨" "Branch merged" = "브랜치 병합됨" @@ -200,6 +205,8 @@ status = "draft — needs native review" "Checking the server…" = "서버 확인 중…" "Chevron" = "셰브런" "Clear from this list" = "이 목록에서 지우기" +"Clear results" = "결과 지우기" +"Click objects to vote; click your badge to take a vote back" = "개체를 클릭해 투표하고, 내 배지를 클릭해 투표를 취소하세요" "Code or invite link" = "코드 또는 초대 링크" "Color profile: {name}" = "색상 프로필: {name}" "CSS custom properties…" = "CSS 사용자 정의 속성…" @@ -439,6 +446,7 @@ status = "draft — needs native review" "Empty" = "비어 있음" "End" = "끝" "End point" = "끝점" +"End vote" = "투표 종료" "Ends the session; everyone keeps their copy" = "세션을 종료함. 각자의 사본은 그대로 유지됨" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "figcraft-server 주소를 입력하고 찾아보기를 눌러 제공되는 플러그인을 확인하세요." @@ -512,6 +520,7 @@ status = "draft — needs native review" "Flip text to the other side" = "텍스트를 반대쪽으로 뒤집기" "Flip vertical" = "세로로 뒤집기" "Flip width profile" = "너비 프로필 뒤집기" +"Flow chart" = "순서도" "Flow starting point" = "플로우 시작점" "Flow starting points" = "플로우 시작점" "Flows" = "플로우" @@ -656,6 +665,7 @@ status = "draft — needs native review" "Join|stroke" = "선 연결" "Just now" = "방금" "Justify" = "양쪽 정렬" +"Kanban board" = "칸반 보드" "Keep a measurement on the canvas for developers" = "개발자를 위해 캔버스에 측정 유지" "Keep appearance" = "모양 유지" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "\"모양 유지\"는 모든 색상 값을 {profile}(으)로 변환해 색이 똑같아 보이게 함. \"값 유지\"는 숫자를 그대로 두므로 색이 달라짐." @@ -748,6 +758,7 @@ status = "draft — needs native review" "Min H" = "최소 H" "Min W" = "최소 W" "Minimize" = "최소화" +"Minutes" = "분" "Mirror angle" = "각도 대칭" "Mirror angle and length" = "각도와 길이 대칭" "Mirroring" = "대칭" @@ -839,6 +850,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "아직 텍스트 스타일이 없음. +를 클릭해 선택한 텍스트로 만드세요." "No variables fit here." = "여기에 맞는 변수가 없음." "No variables yet. Create them in Local variables." = "아직 변수가 없음. 로컬 변수에서 만드세요." +"No votes yet" = "아직 투표가 없습니다" "Nobody else is here yet" = "아직 다른 사람이 없음" "Noise" = "노이즈" "None" = "없음" @@ -920,6 +932,7 @@ status = "draft — needs native review" "Page deleted" = "페이지가 삭제됨" "Pages" = "페이지" "Pages added to the document" = "문서에 페이지 추가됨" +"Pause" = "일시 정지" "Pink" = "분홍" "Placed on the current page" = "현재 페이지에 배치됨" "Pages: {pages}" = "페이지: {pages}" @@ -1031,6 +1044,7 @@ status = "draft — needs native review" "Remove all" = "모두 제거" "Remove annotation" = "주석 제거" "Remove auto layout" = "자동 레이아웃 제거" +"Remove column" = "열 제거" "Remove dev resource" = "개발 리소스 제거" "Remove export setting" = "내보내기 설정 제거" "Remove from server" = "서버에서 제거" @@ -1043,6 +1057,7 @@ status = "draft — needs native review" "Remove min and max height" = "최소 및 최대 높이 제거" "Remove min and max width" = "최소 및 최대 너비 제거" "Remove regions" = "영역 제거" +"Remove row" = "행 제거" "Remove slot" = "슬롯 제거" "Remove starting point" = "시작점 제거" "Remove stop" = "정지점 제거" @@ -1096,6 +1111,9 @@ status = "draft — needs native review" "Restore main component" = "메인 컴포넌트 복원" "Restore this version" = "이 버전 복원" "Restored “{name}”" = "\"{name}\" 복원됨" +"Retrospective" = "회고" +"Results" = "결과" +"Resume" = "재개" "Reverse the order" = "순서 뒤집기" "Reversed triangle" = "역삼각형" "Review and merge" = "검토 및 병합" @@ -1258,17 +1276,20 @@ status = "draft — needs native review" "Start on the Home screen" = "홈에서 시작" "Start point" = "시작점" "Start sharing" = "공유 시작" +"Start vote" = "투표 시작" "Starting to share…" = "공유를 시작하는 중…" "Status" = "상태" "Sticky" = "고정" "Sticky note" = "스티커 메모" "Sticky note colors" = "스티커 메모 색상" +"Sticky notes" = "스티커 메모" "Sticky notes, shapes and connectors" = "스티커 메모, 도형, 커넥터" "Stop" = "중지" "Stop following {name}" = "{name} 따라가기 중지" "Stop session" = "세션 중지" "Stop sharing" = "공유 중지" "Stop spotlighting" = "스포트라이트 중지" +"Stop timer" = "타이머 중지" "Stop {n} {hex}" = "정지점 {n} {hex}" "Straight" = "직선" "Stretch" = "늘이기" @@ -1310,6 +1331,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "무엇이 문제인지 서버 운영자에게 알리기" "Temperature" = "색온도" "Template" = "템플릿" +"Templates" = "템플릿" "Terms" = "약관" "Text" = "텍스트" "Text align" = "텍스트 정렬" @@ -1351,6 +1373,7 @@ status = "draft — needs native review" "Tidy up" = "정돈" "Tile" = "타일" "Tile type" = "타일 유형" +"Timer" = "타이머" "Tint" = "색조 보정" "Title" = "제목" "Title case" = "제목 대소문자" @@ -1442,6 +1465,8 @@ status = "draft — needs native review" "Visibility" = "표시 여부" "Visible" = "표시됨" "Vivid" = "선명하게" +"Votes per person" = "1인당 투표 수" +"Voting session" = "투표 세션" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND가 설정되어 사용할 그래픽 API를 지정하고 있습니다. 설정을 해제하면 FigCraft가 선택합니다." "Warm" = "따뜻하게" "Watch" = "시계" @@ -1450,6 +1475,7 @@ status = "draft — needs native review" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL 복사됨" "Wedge" = "쐐기" +"Weekly planner" = "주간 계획표" "Weight" = "굵기" "What changed since a saved version" = "저장된 버전 이후 변경된 내용" "What changed since it was marked ready for dev" = "개발 준비 완료로 표시된 뒤 변경된 내용" @@ -1653,6 +1679,7 @@ status = "draft — needs native review" "{n} layers selected" = { other = "레이어 {n}개 선택됨" } "{n} layers · {parts}" = { other = "레이어 {n}개 · {parts}" } "{n} matches" = { other = "{n}개 일치" } +"{n} min" = "{n}분" "{n} minutes ago" = { other = "{n}분 전" } "{n} months ago" = { other = "{n}개월 전" } "{n} more notes omitted from this report" = { other = "참고 사항 {n}개가 이 보고서에서 더 생략됨" } @@ -1665,6 +1692,8 @@ status = "draft — needs native review" "{n} rows" = { other = "{n}행" } "{n} styles" = { other = "스타일 {n}개" } "{n} variables" = { other = "변수 {n}개" } +"{n} votes" = { other = "{n}표" } +"{n} votes left" = { other = "남은 투표 {n}개" } "{n} weeks ago" = { other = "{n}주 전" } "{n} years ago" = { other = "{n}년 전" } "{n}d" = "{n}일" @@ -1690,6 +1719,121 @@ status = "draft — needs native review" "⌘ click" = "⌘ 클릭" "⌘ scroll" = "⌘ 스크롤" "⌥ drag" = "⌥ 드래그" +"A responsive product page: hero, features, figures, a quote and a footer" = "대표 영역, 기능, 수치, 인용문, 푸터로 구성된 반응형 제품 페이지" +"A title, an agenda, a big figure, a chart and a closing slide" = "제목, 목차, 주요 수치, 차트, 마무리 슬라이드" +"A to Z" = "이름 오름차순" +"Adding to {name}" = "{name}에 추가 중" +"All the fonts this file uses are installed" = "이 파일에 사용된 모든 글꼴이 설치되어 있습니다." +"Alphabetical" = "가나다순" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "수치, 벡터 차트, 표를 격자로 배치한 분석 화면" +"Apps" = "앱" +"Apr" = "4월" +"Aug" = "8월" +"Bottom padding" = "아래쪽 안쪽 여백" +"Bound to {name}. Click to detach the variable." = "{name}에 연결되어 있습니다. 클릭하면 변수 연결을 해제합니다." +"Checking the fonts installed on this computer…" = "이 컴퓨터에 설치된 글꼴 확인 중…" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "색상 및 텍스트 스타일, 밝게·어둡게 변수, 버튼, 입력란, 카드" +"Convert to frame" = "프레임으로 변환" +"Convert to section" = "섹션으로 변환" +"Couldn't download the variables" = "변수를 다운로드할 수 없습니다." +"Created" = "만든 날짜" +"Dashboard" = "대시보드" +"Date created" = "만든 날짜" +"Dec" = "12월" +"Design system starter" = "디자인 시스템 시작 키트" +"Draw a frame" = "프레임 그리기" +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = ".figcraft, .fig, .sketch, .json, .svg 또는 이미지 파일을 놓으세요." +"Drop to import" = "놓아서 가져오기" +"Drop to open" = "놓아서 열기" +"Edited {when}" = "{when} 편집" +"Export frames and layers as PNG, JPG, SVG or PDF" = "프레임과 레이어를 PNG, JPG, SVG 또는 PDF로 내보내기" +"Feb" = "2월" +"File not found" = "파일을 찾을 수 없습니다." +"Four screens of a trail app with components and a prototype flow" = "컴포넌트와 프로토타입 흐름이 포함된 트레일 앱 화면 4개" +"Getting started" = "시작하기" +"Greyscale components and two example screens to sketch a flow with" = "흐름을 구상할 수 있는 회색 음영 컴포넌트와 예시 화면 2개" +"Hide these tips (Getting started in the Help menu shows them again)" = "팁 숨기기(도움말 메뉴의 시작하기에서 다시 표시)" +"Hide when publishing" = "게시할 때 숨기기" +"Icon components on a 24 px grid with keylines and a size preview" = "키라인과 크기 미리 보기가 포함된 24px 격자의 아이콘 컴포넌트" +"Icon set starter" = "아이콘 세트 시작 키트" +"Individual padding" = "개별 안쪽 여백" +"Interface size" = "인터페이스 크기" +"Jan" = "1월" +"Jul" = "7월" +"Jun" = "6월" +"Kits" = "키트" +"Landing page" = "랜딩 페이지" +"Last modified" = "마지막 수정" +"Left padding" = "왼쪽 안쪽 여백" +"Link frames on the Prototype tab, then present the flow" = "프로토타입 탭에서 프레임을 연결한 다음 흐름을 실행해 보세요." +"Make a component" = "컴포넌트 만들기" +"Mar" = "3월" +"May" = "5월" +"Missing fonts" = "없는 글꼴" +"Mobile app" = "모바일 앱" +"Newest first" = "최신순" +"No files or templates match your search" = "검색 조건에 맞는 파일이나 템플릿이 없습니다." +"No other pages" = "다른 페이지 없음" +"No templates match your search" = "검색 조건에 맞는 템플릿이 없습니다." +"Nov" = "11월" +"Oct" = "10월" +"Oldest first" = "오래된순" +"One announcement in five common sizes: square, portrait, story, link and banner" = "하나의 공지를 정사각형, 세로형, 스토리, 링크, 배너의 일반적인 5가지 크기로 구성" +"Opening the file…" = "파일 여는 중…" +"Opening {n} files as new files" = { other = "파일 {n}개를 새 파일로 여는 중" } +"Order" = "순서" +"Other" = "기타" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "프레임 도구를 선택한 뒤 캔버스에서 드래그하거나 기기 크기를 고르세요." +"Plugin" = "플러그인" +"Plugins & widgets" = "플러그인 및 위젯" +"Presentation slides" = "프레젠테이션 슬라이드" +"Playground" = "실험 공간" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "시스템 디스플레이 배율을 기준으로 합니다(100%는 시스템 기본 크기). 문서 확대/축소는 별도입니다." +"Remove from recents" = "최근 목록에서 제거" +"Reorder selection" = "선택 항목 순서 변경" +"Right padding" = "오른쪽 안쪽 여백" +"Search templates" = "템플릿 검색" +"See all templates" = "모든 템플릿 보기" +"Select layers and they space themselves as their content changes" = "레이어를 선택하면 내용 변경에 맞춰 간격이 자동으로 조정됩니다." +"Search files and templates" = "파일 및 템플릿 검색" +"Search results" = "검색 결과" +"Search pages" = "페이지 검색" +"Sep" = "9월" +"Share with a code" = "코드로 공유" +"Show all templates" = "모든 템플릿 표시" +"Show in Explorer" = "탐색기에서 보기" +"Show in Finder" = "Finder에서 보기" +"Show in folder" = "폴더에서 보기" +"Social and slides" = "소셜 및 슬라이드" +"Social post sizes" = "소셜 게시물 크기" +"Sort by" = "정렬 기준" +"Sort by {column}" = "{column} 기준 정렬" +"Start from a template" = "템플릿으로 시작" +"Start sharing with the Share button, then pass on the code it gives" = "공유 버튼으로 공유를 시작한 뒤 표시되는 코드를 전달하세요." +"The destination document is unavailable. Nothing was imported." = "대상 문서를 사용할 수 없습니다. 아무것도 가져오지 않았습니다." +"The destination document was closed. Nothing was imported." = "대상 문서가 닫혔습니다. 아무것도 가져오지 않았습니다." +"The destination page was removed. Nothing was imported." = "대상 페이지가 삭제되었습니다. 아무것도 가져오지 않았습니다." +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "창이 너무 작아 {size}% 대신 {drawn}%로 표시합니다. {size}%를 사용하려면 창을 키우세요." +"This file is too large to join here (up to {mb} MB)" = "이 파일은 여기에 추가하기에 너무 큽니다(최대 {mb}MB)." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "이 파일은 이 컴퓨터에 없는 글꼴을 사용합니다. 해당 글꼴을 설치할 때까지 대체 글꼴로 표시합니다." +"Top padding" = "위쪽 안쪽 여백" +"Try another search, or browse templates." = "다른 검색어를 입력하거나 템플릿을 둘러보세요." +"Try another word, or look at every kind." = "다른 단어를 검색하거나 모든 종류를 확인하세요." +"Turn a layer into a component, reuse it, and change every copy at once" = "레이어를 컴포넌트로 만들고 재사용하여 모든 사본을 한 번에 변경하세요." +"Use your browser's zoom to change the interface size." = "인터페이스 크기는 브라우저의 확대/축소로 변경하세요." +"Uses variables: each layer shows them in its own mode" = "변수 사용: 각 레이어의 모드에 따라 표시합니다." +"Websites" = "웹사이트" +"What you try here changes this preview and the code below, not the file" = "여기서 변경한 내용은 미리 보기와 아래 코드에만 적용되며 파일은 바뀌지 않습니다." +"Widget" = "위젯" +"Wireframe kit" = "와이어프레임 키트" +"Z to A" = "이름 내림차순" +"just now" = "방금" +"only in a text style or variable" = "텍스트 스타일 또는 변수에서만 사용" +"slot layers without a matching slot property were imported as frames" = "일치하는 슬롯 속성이 없는 슬롯 레이어를 프레임으로 가져왔습니다." +"yesterday" = "어제" +"{month} {day}, {year}" = "{year}년 {month} {day}일" +"{n} text layers" = { other = "텍스트 레이어 {n}개" } +"{percent}%" = "{percent}%" [commands] "align.bottom" = "아래쪽 정렬" @@ -1727,6 +1871,14 @@ status = "draft — needs native review" "board.shape" = "텍스트가 있는 도형" "board.stamp" = "스탬프" "board.sticky" = "스티커 메모" +"board.table" = "표" +"board.tableEdit" = "표 편집" +"board.template" = "보드 템플릿" +"board.templates" = "보드 템플릿 목록" +"board.timer" = "타이머" +"board.timerState" = "타이머 상태" +"board.vote" = "투표 세션" +"board.voteResults" = "투표 결과" "bool.exclude" = "선택 항목 제외" "bool.flatten" = "불리언 평면화" "bool.intersect" = "선택 항목 교차" @@ -2271,8 +2423,28 @@ status = "draft — needs native review" "widget.click" = "위젯 클릭" "widget.insert" = "위젯 삽입" "widget.list" = "위젯 목록" +"widget.menu" = "위젯 메뉴" +"widget.setText" = "위젯 필드 편집" "window.focus" = "창을 맨 앞으로 가져오기" "window.new" = "새 창" +"arrange.reorder" = "선택 항목 순서 변경" +"component.hideWhenPublishing" = "게시할 때 숨기기" +"object.convertToGroup" = "그룹으로 변환" +"object.convertToFrame" = "프레임으로 변환" +"object.convertToSection" = "섹션으로 변환" +"text.rescanFonts" = "설치된 글꼴 다시 검색" +"text.loadFont" = "글꼴 파일 불러오기" +"view.outlinesHiddenLayers" = "윤곽선에 숨겨진 레이어 포함" +"view.outlinesObjectBounds" = "윤곽선에 개체 경계 포함" +"view.panelWidths" = "패널 너비 초기화" +"pref.uiScale" = "인터페이스 크기" +"help.gettingStarted" = "시작하기" +"style.bind" = "스타일에 변수 연결" +"component.playground" = "컴포넌트 실험실" +"text.missingFonts" = "누락된 글꼴" +"text.refit" = "글꼴에 맞게 텍스트 조정" +"template.list" = "템플릿 목록" +"file.newFromTemplate" = "템플릿에서 새로 만들기" [menu] "bool.exclude" = "제외" @@ -2304,3 +2476,5 @@ status = "draft — needs native review" "view.themeDark" = "다크" "view.themeLight" = "라이트" "view.themeSystem" = "시스템 따르기" +"view.outlinesHiddenLayers" = "숨겨진 레이어 포함" +"view.outlinesObjectBounds" = "개체 경계 포함" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index c25775f6..6cac7aa9 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -38,6 +38,7 @@ status = "draft — needs native review" "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" +"Add a minute" = "Een minuut toevoegen" "Add a note for developers" = "Notitie voor ontwikkelaars toevoegen" "Add action" = "Actie toevoegen" "Add actions for when no condition holds" = "Voeg acties toe voor het geval geen enkele voorwaarde geldt" @@ -45,6 +46,7 @@ status = "draft — needs native review" "Add another condition" = "Nog een voorwaarde toevoegen" "Add auto layout" = "Auto layout toevoegen" "Add background behind overlay" = "Achtergrond achter overlay toevoegen" +"Add column" = "Kolom toevoegen" "Add content to this slot" = "Voeg inhoud toe aan dit slot" "Add dev resource" = "Dev-resource toevoegen" "Add effect" = "Effect toevoegen" @@ -64,6 +66,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Voeg verzamelingen toe of werk ze bij vanuit een variables-JSON" "Add properties to make instances configurable" = "Voeg eigenschappen toe om instances configureerbaar te maken" "Add reaction" = "Reactie toevoegen" +"Add row" = "Rij toevoegen" "Add starting point" = "Startpunt toevoegen" "Add stroke" = "Lijn toevoegen" "Add variant" = "Variant toevoegen" @@ -93,6 +96,7 @@ status = "draft — needs native review" "All comments" = "Alle reacties" "All components" = "Alle components" "All fonts" = "Alle lettertypen" +"All objects" = "Alle objecten" "All supported properties" = "Alle ondersteunde eigenschappen" "All the fonts this file uses are installed" = "Alle lettertypen die dit bestand gebruikt zijn geïnstalleerd" "All variables" = "Alle variabelen" @@ -183,6 +187,7 @@ status = "draft — needs native review" "Bottom left radius" = "Radius linksonder" "Bottom right radius" = "Radius rechtsonder" "Bound to {name}. Click to detach the variable." = "Gekoppeld aan {name}. Klik om de variabele los te koppelen." +"Brainstorm" = "Brainstorm" "Branch" = "Branch" "Branch archived" = "Branch gearchiveerd" "Branch merged" = "Branch samengevoegd" @@ -241,8 +246,10 @@ status = "draft — needs native review" "Circle" = "Cirkel" "Clear from this list" = "Verwijderen uit deze lijst" "Clear recent" = "Recenten wissen" +"Clear results" = "Resultaten wissen" "Click + to replace mixed content" = "Klik op + om gemengde inhoud te vervangen" "Click anywhere on the canvas to leave one." = "Klik ergens op het canvas om er een achter te laten." +"Click objects to vote; click your badge to take a vote back" = "Klik op objecten om te stemmen; klik op je badge om een stem terug te nemen" "Click or drag to merge · {alt} removes" = "Klik of sleep om samen te voegen · {alt} verwijdert" "Click or drag to remove · {alt} merges" = "Klik of sleep om te verwijderen · {alt} voegt samen" "Click to record a key" = "Klik om een toets op te nemen" @@ -463,6 +470,7 @@ status = "draft — needs native review" "Empty" = "Leeg" "End" = "End" "End point" = "Eindpunt" +"End vote" = "Stemronde beëindigen" "Ends the session; everyone keeps their copy" = "Beëindigt de sessie; iedereen houdt zijn eigen kopie" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Voer het adres van een figcraft-server in en druk op Bladeren om te zien welke plugins het aanbiedt." @@ -539,6 +547,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Tekst naar de andere kant spiegelen" "Flip vertical" = "Verticaal spiegelen" "Flip width profile" = "Breedteprofiel spiegelen" +"Flow chart" = "Stroomdiagram" "Flow starting point" = "Flow-startpunt" "Flow starting points" = "Flow-startpunten" "Flows" = "Flows" @@ -693,6 +702,7 @@ status = "draft — needs native review" "Jun" = "jun." "Just now" = "Zojuist" "Justify" = "Uitvullen" +"Kanban board" = "Kanbanbord" "Keep a measurement on the canvas for developers" = "Een meting voor ontwikkelaars op het canvas houden" "Keep appearance" = "Uiterlijk behouden" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Kleur behouden converteert elke kleurwaarde naar {profile}, zodat kleuren er hetzelfde uitzien. Waarden behouden laat de getallen staan, waardoor kleuren verschuiven." @@ -792,6 +802,7 @@ status = "draft — needs native review" "Min H" = "Min H" "Min W" = "Min. B" "Minimize" = "Minimaliseren" +"Minutes" = "Minuten" "Mirror angle" = "Hoek spiegelen" "Mirror angle and length" = "Spiegel hoek en lengte" "Mirroring" = "Spiegeling" @@ -889,6 +900,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Nog geen tekststijlen. Klik op + om er een te maken vanuit de geselecteerde tekst." "No variables fit here." = "Er passen hier geen variabelen." "No variables yet. Create them in Local variables." = "Nog geen variabelen. Maak ze in Lokale variabelen." +"No votes yet" = "Nog geen stemmen" "Nobody else is here yet" = "Er is nog niemand anders hier" "Noise" = "Ruis" "None" = "Geen" @@ -996,6 +1008,7 @@ status = "draft — needs native review" "Pattern alignment" = "Patroonuitlijning" "Pattern scale" = "Patroonschaal" "Pattern source" = "Patroonbron" +"Pause" = "Pauzeren" "Pen" = "Pen" "Pencil" = "Potlood" "People in this file" = "Personen in dit bestand" @@ -1093,6 +1106,7 @@ status = "draft — needs native review" "Remove all" = "Alles verwijderen" "Remove annotation" = "Annotatie verwijderen" "Remove auto layout" = "Auto layout verwijderen" +"Remove column" = "Kolom verwijderen" "Remove dev resource" = "Dev-resource verwijderen" "Remove export setting" = "Exportinstelling verwijderen" "Remove from recents" = "Verwijderen uit onlangs geopend" @@ -1106,6 +1120,7 @@ status = "draft — needs native review" "Remove min and max height" = "Minimale en maximale hoogte verwijderen" "Remove min and max width" = "Minimale en maximale breedte verwijderen" "Remove regions" = "Gebieden verwijderen" +"Remove row" = "Rij verwijderen" "Remove slot" = "Vak verwijderen" "Remove starting point" = "Startpunt verwijderen" "Remove stop" = "Stop verwijderen" @@ -1158,6 +1173,9 @@ status = "draft — needs native review" "Restore main component" = "Hoofdcomponent terugzetten" "Restore this version" = "Deze versie terugzetten" "Restored “{name}”" = "“{name}” teruggezet" +"Retrospective" = "Retrospectief" +"Results" = "Resultaten" +"Resume" = "Hervatten" "Reverse the order" = "Volgorde omdraaien" "Reversed triangle" = "Omgekeerde driehoek" "Review and merge" = "Controleren en samenvoegen" @@ -1337,17 +1355,20 @@ status = "draft — needs native review" "Start point" = "Startpunt" "Start sharing" = "Delen starten" "Start sharing with the Share button, then pass on the code it gives" = "Begin met delen via de knop Delen en geef daarna de code door die je krijgt" +"Start vote" = "Stemronde starten" "Starting to share…" = "Bezig met delen…" "Status" = "Status" "Sticky" = "Sticky" "Sticky note" = "Plakbriefje" "Sticky note colors" = "Kleuren van plakbriefjes" +"Sticky notes" = "Plakbriefjes" "Sticky notes, shapes and connectors" = "Plakbriefjes, vormen en verbindingen" "Stop" = "Stoppen" "Stop following {name}" = "Stop met {name} te volgen" "Stop session" = "Sessie stoppen" "Stop sharing" = "Stoppen met delen" "Stop spotlighting" = "Stop met uitlichten" +"Stop timer" = "Timer stoppen" "Stop {n} {hex}" = "Stop {n} {hex}" "Straight" = "Recht" "Stretch" = "Uitrekken" @@ -1433,6 +1454,7 @@ status = "draft — needs native review" "Tidy up" = "Opruimen" "Tile" = "Tegelen" "Tile type" = "Tegeltype" +"Timer" = "Timer" "Tint" = "Tint" "Title" = "Titel" "Title case" = "Hoofdletter per woord" @@ -1528,6 +1550,8 @@ status = "draft — needs native review" "Visibility" = "Zichtbaarheid" "Visible" = "Zichtbaar" "Vivid" = "Vivid" +"Votes per person" = "Stemmen per persoon" +"Voting session" = "Stemronde" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is ingesteld en geeft de te gebruiken grafische API aan; als je het uitzet, kiest FigCraft zelf." "Warm" = "Warm" "Watch" = "Watch" @@ -1537,6 +1561,7 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket-URL gekopieerd" "Websites" = "Websites" "Wedge" = "Wedge" +"Weekly planner" = "Weekplanner" "Weight" = "Dikte" "What changed since it was marked ready for dev" = "Wat er veranderd is sinds het is gemarkeerd als klaar voor Dev Mode" "What is wrong with this file? The server's operator gets your report." = "Wat klopt er niet aan dit bestand? Je melding gaat naar de beheerder van de server." @@ -1746,6 +1771,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} laag geselecteerd", other = "{n} lagen geselecteerd" } "{n} layers · {parts}" = { one = "{n} laag · {parts}", other = "{n} lagen · {parts}" } "{n} matches" = { one = "{n} overeenkomst", other = "{n} overeenkomsten" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "{n} minuut geleden", other = "{n} minuten geleden" } "{n} months ago" = { one = "{n} maand geleden", other = "{n} maanden geleden" } "{n} more notes omitted from this report" = { one = "{n} extra notitie weggelaten uit dit rapport", other = "{n} extra notities weggelaten uit dit rapport" } @@ -1759,6 +1785,8 @@ status = "draft — needs native review" "{n} styles" = { one = "{n} stijl", other = "{n} stijlen" } "{n} text layers" = { one = "{n} tekstlaag", other = "{n} tekstlagen" } "{n} variables" = { one = "{n} variabele", other = "{n} variabelen" } +"{n} votes" = { one = "{n} stem", other = "{n} stemmen" } +"{n} votes left" = { one = "Nog {n} stem", other = "Nog {n} stemmen" } "{n} weeks ago" = { one = "{n} week geleden", other = "{n} weken geleden" } "{n} years ago" = { one = "{n} jaar geleden", other = "{n} jaar geleden" } "{n}d" = "{n}d" @@ -1821,6 +1849,14 @@ status = "draft — needs native review" "board.shape" = "Vorm met tekst" "board.stamp" = "Stempel" "board.sticky" = "Plakbriefje" +"board.table" = "Tabel" +"board.tableEdit" = "Tabel bewerken" +"board.template" = "Whiteboardsjabloon" +"board.templates" = "Whiteboardsjablonen weergeven" +"board.timer" = "Timer" +"board.timerState" = "Timerstatus" +"board.vote" = "Stemronde" +"board.voteResults" = "Stemresultaten" "bool.exclude" = "Exclude toepassen op selectie" "bool.flatten" = "Boolean afvlakken" "bool.intersect" = "Intersect toepassen op selectie" @@ -2375,6 +2411,8 @@ status = "draft — needs native review" "widget.click" = "Op widget klikken" "widget.insert" = "Widget invoegen" "widget.list" = "Widgets weergeven" +"widget.menu" = "Widgetmenu" +"widget.setText" = "Widgetveld bewerken" "window.focus" = "Venster naar voren brengen" "window.new" = "Nieuw venster" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 30a9180a..a435726c 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -36,6 +36,7 @@ name = "Polski" "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" +"Add a minute" = "Dodaj minutę" "Add a note for developers" = "Dodaj notatkę dla programistów" "Add action" = "Dodaj akcję" "Add actions for when no condition holds" = "Dodaj akcje wykonywane, gdy żaden warunek nie jest spełniony" @@ -43,6 +44,7 @@ name = "Polski" "Add another condition" = "Dodaj kolejny warunek" "Add auto layout" = "Dodaj Auto layout" "Add background behind overlay" = "Dodaj tło za nakładką" +"Add column" = "Dodaj kolumnę" "Add content to this slot" = "Dodaj zawartość do tego gniazda" "Add dev resource" = "Dodaj zasób programistyczny" "Add effect" = "Dodaj efekt" @@ -62,6 +64,7 @@ name = "Polski" "Add or update collections from a variables JSON" = "Dodaj lub zaktualizuj kolekcje z pliku JSON ze zmiennymi" "Add properties to make instances configurable" = "Dodaj właściwości, aby umożliwić konfigurację instancji" "Add reaction" = "Dodaj reakcję" +"Add row" = "Dodaj wiersz" "Add starting point" = "Dodaj punkt startowy" "Add stroke" = "Dodaj obrys" "Add variant" = "Dodaj wariant" @@ -91,6 +94,7 @@ name = "Polski" "All comments" = "Wszystkie komentarze" "All components" = "Wszystkie komponenty" "All fonts" = "Wszystkie czcionki" +"All objects" = "Wszystkie obiekty" "All supported properties" = "Wszystkie obsługiwane właściwości" "All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" "All variables" = "Wszystkie zmienne" @@ -181,6 +185,7 @@ name = "Polski" "Bottom left radius" = "Promień lewego dolnego narożnika" "Bottom padding" = "Margines dolny" "Bottom right radius" = "Promień prawego dolnego narożnika" +"Brainstorm" = "Burza mózgów" "Branch" = "Gałąź" "Branch archived" = "Gałąź zarchiwizowana" "Branch merged" = "Gałąź scalona" @@ -211,6 +216,8 @@ name = "Polski" "Checking the server…" = "Sprawdzanie serwera…" "Chevron" = "Szewron" "Clear from this list" = "Usuń z tej listy" +"Clear results" = "Wyczyść wyniki" +"Click objects to vote; click your badge to take a vote back" = "Kliknij obiekty, aby głosować; kliknij swoją odznakę, aby cofnąć głos" "Code or invite link" = "Kod lub link z zaproszeniem" "Color profile: {name}" = "Profil kolorów: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Style kolorów i tekstu, zmienne Jasne i Ciemne, przyciski, pola wprowadzania i karty" @@ -462,6 +469,7 @@ name = "Polski" "Empty" = "Puste" "End" = "End" "End point" = "Punkt końcowy" +"End vote" = "Zakończ głosowanie" "Ends the session; everyone keeps their copy" = "Kończy sesję; każdy zachowuje swoją kopię" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Wprowadź adres figcraft-server i kliknij Przeglądaj, aby zobaczyć dostępne wtyczki." @@ -538,6 +546,7 @@ name = "Polski" "Flip text to the other side" = "Przerzuć tekst na drugą stronę" "Flip vertical" = "Odbij w pionie" "Flip width profile" = "Odwróć profil szerokości" +"Flow chart" = "Schemat blokowy" "Flow starting point" = "Punkt startowy przepływu" "Flow starting points" = "Punkty startowe przepływu" "Flows" = "Przepływy" @@ -694,6 +703,7 @@ name = "Polski" "Jun" = "Cze" "Just now" = "Przed chwilą" "Justify" = "Wyjustuj" +"Kanban board" = "Tablica kanban" "Keep a measurement on the canvas for developers" = "Zachowaj pomiar na płótnie dla programistów" "Keep appearance" = "Zachowaj wygląd" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." @@ -794,6 +804,7 @@ name = "Polski" "Min H" = "Min. wys." "Min W" = "Min. szer." "Minimize" = "Minimalizuj" +"Minutes" = "Minuty" "Mirror angle" = "Kąt odbicia lustrzanego" "Mirror angle and length" = "Kąt i długość odbicia lustrzanego" "Mirroring" = "Odbicie lustrzane" @@ -891,6 +902,7 @@ name = "Polski" "No text styles yet. Click + to create one from the selected text." = "Brak stylów tekstu. Kliknij +, aby utworzyć styl z zaznaczonego tekstu." "No variables fit here." = "Żadne zmienne tutaj nie pasują." "No variables yet. Create them in Local variables." = "Brak zmiennych. Utwórz je w Zmiennych lokalnych." +"No votes yet" = "Brak głosów" "Nobody else is here yet" = "Nikogo innego tu jeszcze nie ma" "Noise" = "Szum" "None" = "Brak" @@ -979,6 +991,7 @@ name = "Polski" "Page deleted" = "Strona została usunięta" "Pages" = "Strony" "Pages added to the document" = "Strony dodane do dokumentu" +"Pause" = "Wstrzymaj" "Pink" = "Różowy" "Placed on the current page" = "Umieszczono na bieżącej stronie" "Pages: {pages}" = "Strony: {pages}" @@ -1096,6 +1109,7 @@ name = "Polski" "Remove all" = "Usuń wszystko" "Remove annotation" = "Usuń adnotację" "Remove auto layout" = "Usuń Auto layout" +"Remove column" = "Usuń kolumnę" "Remove dev resource" = "Usuń zasób programistyczny" "Remove export setting" = "Usuń ustawienie eksportu" "Remove from recents" = "Usuń z ostatnich" @@ -1109,6 +1123,7 @@ name = "Polski" "Remove min and max height" = "Usuń minimalną i maksymalną wysokość" "Remove min and max width" = "Usuń minimalną i maksymalną szerokość" "Remove regions" = "Usuń obszary" +"Remove row" = "Usuń wiersz" "Remove slot" = "Usuń gniazdo" "Remove starting point" = "Usuń punkt startowy" "Remove stop" = "Usuń punkt" @@ -1162,6 +1177,9 @@ name = "Polski" "Restore main component" = "Przywróć główny komponent" "Restore this version" = "Przywróć tę wersję" "Restored “{name}”" = "Przywrócono „{name}”" +"Retrospective" = "Retrospektywa" +"Results" = "Wyniki" +"Resume" = "Wznów" "Reverse the order" = "Odwróć kolejność" "Reversed triangle" = "Odwrócony trójkąt" "Review and merge" = "Przejrzyj i scal" @@ -1343,17 +1361,20 @@ name = "Polski" "Start point" = "Punkt początkowy" "Start sharing" = "Rozpocznij udostępnianie" "Start sharing with the Share button, then pass on the code it gives" = "Rozpocznij udostępnianie przyciskiem Udostępnij, a następnie przekaż wygenerowany kod" +"Start vote" = "Rozpocznij głosowanie" "Starting to share…" = "Rozpoczynanie udostępniania…" "Status" = "Stan" "Sticky" = "Przyklejony (sticky)" "Sticky note" = "Karteczka" "Sticky note colors" = "Kolory karteczek" +"Sticky notes" = "Karteczki" "Sticky notes, shapes and connectors" = "Karteczki, kształty i łączniki" "Stop" = "Zatrzymaj" "Stop following {name}" = "Przestań obserwować: {name}" "Stop session" = "Zatrzymaj sesję" "Stop sharing" = "Zatrzymaj udostępnianie" "Stop spotlighting" = "Zatrzymaj wyróżnianie widoku" +"Stop timer" = "Zatrzymaj minutnik" "Stop {n} {hex}" = "Punkt przejścia {n} {hex}" "Straight" = "Prosty" "Stretch" = "Rozciągnięcie" @@ -1441,6 +1462,7 @@ name = "Polski" "Tidy up" = "Uporządkuj" "Tile" = "Sąsiadująco" "Tile type" = "Typ kafelka" +"Timer" = "Minutnik" "Tint" = "Odcień barwny" "Title" = "Tytuł" "Title case" = "Jak W Tytule" @@ -1538,6 +1560,8 @@ name = "Polski" "Visibility" = "Widoczność" "Visible" = "Widoczne" "Vivid" = "Żywe kolory" +"Votes per person" = "Głosy na osobę" +"Voting session" = "Głosowanie" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Zmienna WGPU_BACKEND jest ustawiona i określa używane API graficzne; jej usunięcie pozwala FigCraft dokonać wyboru." "Warm" = "Ciepły" "Watch" = "Zegarek" @@ -1547,6 +1571,7 @@ name = "Polski" "WebSocket URL" = "Adres WebSocket URL" "WebSocket URL copied" = "Skopiowano adres WebSocket URL" "Wedge" = "Klin" +"Weekly planner" = "Plan tygodnia" "Weight" = "Grubość" "What you try here changes this preview and the code below, not the file" = "To, co wypróbujesz tutaj, zmienia ten podgląd i kod poniżej, a nie plik" "What changed since it was marked ready for dev" = "Co się zmieniło od czasu oznaczenia jako gotowe do wdrożenia" @@ -1757,6 +1782,7 @@ name = "Polski" "{n} layers selected" = { one = "Zaznaczono {n} warstwę", few = "Zaznaczono {n} warstwy", many = "Zaznaczono {n} warstw", other = "Zaznaczono {n} warstw" } "{n} layers · {parts}" = { one = "{n} warstwa · {parts}", few = "{n} warstwy · {parts}", many = "{n} warstw · {parts}", other = "{n} warstw · {parts}" } "{n} matches" = { one = "{n} dopasowanie", few = "{n} dopasowania", many = "{n} dopasowań", other = "{n} dopasowań" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "{n} minutę temu", few = "{n} minuty temu", many = "{n} minut temu", other = "{n} minut temu" } "{n} months ago" = { one = "{n} miesiąc temu", few = "{n} miesiące temu", many = "{n} miesięcy temu", other = "{n} miesięcy temu" } "{n} more notes omitted from this report" = { one = "Pominięto jeszcze {n} notatkę w tym raporcie", few = "Pominięto jeszcze {n} notatki w tym raporcie", many = "Pominięto jeszcze {n} notatek w tym raporcie", other = "Pominięto jeszcze {n} notatek w tym raporcie" } @@ -1770,6 +1796,8 @@ name = "Polski" "{n} styles" = { one = "{n} styl", few = "{n} style", many = "{n} stylów", other = "{n} stylów" } "{n} text layers" = { one = "{n} warstwa tekstowa", few = "{n} warstwy tekstowe", many = "{n} warstw tekstowych", other = "{n} warstw tekstowych" } "{n} variables" = { one = "{n} zmienna", few = "{n} zmienne", many = "{n} zmiennych", other = "{n} zmiennych" } +"{n} votes" = { one = "{n} głos", few = "{n} głosy", many = "{n} głosów", other = "{n} głosu" } +"{n} votes left" = { one = "Pozostał {n} głos", few = "Pozostały {n} głosy", many = "Pozostało {n} głosów", other = "Pozostało {n} głosu" } "{n} weeks ago" = { one = "{n} tydzień temu", few = "{n} tygodnie temu", many = "{n} tygodni temu", other = "{n} tygodni temu" } "{n} years ago" = { one = "{n} rok temu", few = "{n} lata temu", many = "{n} lat temu", other = "{n} lat temu" } "{n}d" = "{n} d" @@ -1834,6 +1862,14 @@ name = "Polski" "board.shape" = "Kształt z tekstem" "board.stamp" = "Pieczątka" "board.sticky" = "Karteczka" +"board.table" = "Tabela" +"board.tableEdit" = "Edytuj tabelę" +"board.template" = "Szablon tablicy" +"board.templates" = "Lista szablonów tablicy" +"board.timer" = "Minutnik" +"board.timerState" = "Stan minutnika" +"board.vote" = "Głosowanie" +"board.voteResults" = "Wyniki głosowania" "bool.exclude" = "Wyklucz zaznaczenie" "bool.flatten" = "Spłaszcz operację logiczną" "bool.intersect" = "Część wspólna zaznaczenia" @@ -2393,6 +2429,8 @@ name = "Polski" "widget.click" = "Kliknij widżet" "widget.insert" = "Wstaw widżet" "widget.list" = "Lista widżetów" +"widget.menu" = "Menu widżetu" +"widget.setText" = "Edytuj pole widżetu" "window.focus" = "Przesuń okno na wierzch" "window.new" = "Nowe okno" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 713e6a3c..ff73e5bc 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -33,6 +33,7 @@ status = "draft — needs native review" "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" +"Add a minute" = "Adicionar um minuto" "Add a note for developers" = "Adicione uma nota para desenvolvedores" "Add action" = "Adicionar ação" "Add actions for when no condition holds" = "Adicionar ações para quando nenhuma condição for atendida" @@ -40,6 +41,7 @@ status = "draft — needs native review" "Add another condition" = "Adicionar outra condição" "Add auto layout" = "Adicionar layout automático" "Add background behind overlay" = "Adicionar fundo atrás da sobreposição" +"Add column" = "Adicionar coluna" "Add content to this slot" = "Adicionar conteúdo a este slot" "Add dev resource" = "Adicionar recurso de desenvolvimento" "Add effect" = "Adicionar efeito" @@ -59,6 +61,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Adicionar ou atualizar coleções a partir de um JSON de variáveis" "Add properties to make instances configurable" = "Adicione propriedades para tornar as instâncias configuráveis" "Add reaction" = "Adicionar reação" +"Add row" = "Adicionar linha" "Add starting point" = "Adicionar ponto de partida" "Add stroke" = "Adicionar traço" "Add variant" = "Adicionar variante" @@ -87,6 +90,7 @@ status = "draft — needs native review" "All comments" = "Todos os comentários" "All components" = "Todos os componentes" "All fonts" = "Todas as fontes" +"All objects" = "Todos os objetos" "All supported properties" = "Todas as propriedades compatíveis" "All variables" = "Todas as variáveis" "Along the bottom" = "Ao longo da borda inferior" @@ -170,6 +174,7 @@ status = "draft — needs native review" "Bottom" = "Embaixo" "Bottom left radius" = "Raio inferior esquerdo" "Bottom right radius" = "Raio inferior direito" +"Brainstorm" = "Brainstorming" "Branch" = "Branch" "Branch archived" = "Branch arquivada" "Branch merged" = "Branch mesclada" @@ -198,6 +203,8 @@ status = "draft — needs native review" "Checking the server…" = "Verificando o servidor…" "Chevron" = "Divisa" "Clear from this list" = "Limpar desta lista" +"Clear results" = "Limpar resultados" +"Click objects to vote; click your badge to take a vote back" = "Clique nos objetos para votar; clique no seu selo para retirar um voto" "Code or invite link" = "Código ou link de convite" "Color profile: {name}" = "Perfil de cor: {name}" "CSS custom properties…" = "Propriedades personalizadas CSS…" @@ -438,6 +445,7 @@ status = "draft — needs native review" "Empty" = "Vazio" "End" = "Fim" "End point" = "Ponto final" +"End vote" = "Encerrar votação" "Ends the session; everyone keeps their copy" = "Encerra a sessão; cada pessoa fica com a própria cópia" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Digite o endereço de um figcraft-server e pressione Procurar para ver os plugins que ele oferece." @@ -511,6 +519,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Passar o texto para o outro lado" "Flip vertical" = "Inverter verticalmente" "Flip width profile" = "Inverter perfil de largura" +"Flow chart" = "Fluxograma" "Flow starting point" = "Ponto de partida do fluxo" "Flow starting points" = "Pontos de partida de fluxo" "Flows" = "Fluxos" @@ -655,6 +664,7 @@ status = "draft — needs native review" "Join|stroke" = "Junção" "Just now" = "Agora mesmo" "Justify" = "Justificar" +"Kanban board" = "Quadro kanban" "Keep a measurement on the canvas for developers" = "Manter uma medição no canvas para desenvolvedores" "Keep appearance" = "Manter aparência" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Manter aparência converte cada valor de cor para {profile} para que as cores pareçam iguais. Manter valores deixa os números como estão, então as cores mudam." @@ -747,6 +757,7 @@ status = "draft — needs native review" "Min H" = "Alt. mín." "Min W" = "Larg. mín." "Minimize" = "Minimizar" +"Minutes" = "Minutos" "Mirror angle" = "Espelhar ângulo" "Mirror angle and length" = "Espelhar ângulo e comprimento" "Mirroring" = "Espelhamento" @@ -838,6 +849,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Ainda não há estilos de texto. Clique em + para criar um a partir do texto selecionado." "No variables fit here." = "Nenhuma variável se encaixa aqui." "No variables yet. Create them in Local variables." = "Ainda não há variáveis. Crie-as em Variáveis locais." +"No votes yet" = "Nenhum voto ainda" "Nobody else is here yet" = "Ainda não há mais ninguém aqui" "Noise" = "Ruído" "None" = "Nenhum" @@ -919,6 +931,7 @@ status = "draft — needs native review" "Page deleted" = "Página excluída" "Pages" = "Páginas" "Pages added to the document" = "Páginas adicionadas ao documento" +"Pause" = "Pausar" "Pink" = "Rosa" "Placed on the current page" = "Colocado na página atual" "Pages: {pages}" = "Páginas: {pages}" @@ -1030,6 +1043,7 @@ status = "draft — needs native review" "Remove all" = "Remover tudo" "Remove annotation" = "Remover anotação" "Remove auto layout" = "Remover layout automático" +"Remove column" = "Remover coluna" "Remove dev resource" = "Remover recurso de desenvolvimento" "Remove export setting" = "Remover configuração de exportação" "Remove from server" = "Remover do servidor" @@ -1042,6 +1056,7 @@ status = "draft — needs native review" "Remove min and max height" = "Remover altura mín. e máx." "Remove min and max width" = "Remover largura mín. e máx." "Remove regions" = "Remover regiões" +"Remove row" = "Remover linha" "Remove slot" = "Remover slot" "Remove starting point" = "Remover ponto de partida" "Remove stop" = "Remover parada" @@ -1095,6 +1110,9 @@ status = "draft — needs native review" "Restore main component" = "Restaurar componente principal" "Restore this version" = "Restaurar esta versão" "Restored “{name}”" = "“{name}” restaurada" +"Retrospective" = "Retrospectiva" +"Results" = "Resultados" +"Resume" = "Retomar" "Reverse the order" = "Inverter a ordem" "Reversed triangle" = "Triângulo invertido" "Review and merge" = "Revisar e mesclar" @@ -1257,17 +1275,20 @@ status = "draft — needs native review" "Start on the Home screen" = "Começar no Início" "Start point" = "Ponto inicial" "Start sharing" = "Começar a compartilhar" +"Start vote" = "Iniciar votação" "Starting to share…" = "Começando a compartilhar…" "Status" = "Status" "Sticky" = "Fixado" "Sticky note" = "Nota adesiva" "Sticky note colors" = "Cores das notas adesivas" +"Sticky notes" = "Notas adesivas" "Sticky notes, shapes and connectors" = "Notas adesivas, formas e conectores" "Stop" = "Parar" "Stop following {name}" = "Parar de seguir {name}" "Stop session" = "Encerrar sessão" "Stop sharing" = "Parar de compartilhar" "Stop spotlighting" = "Remover o foco" +"Stop timer" = "Parar timer" "Stop {n} {hex}" = "Parada {n} {hex}" "Straight" = "Reto" "Stretch" = "Esticar" @@ -1350,6 +1371,7 @@ status = "draft — needs native review" "Tidy up" = "Organizar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" +"Timer" = "Timer" "Tint" = "Tonalidade" "Title" = "Título" "Title case" = "Iniciais maiúsculas" @@ -1441,6 +1463,8 @@ status = "draft — needs native review" "Visibility" = "Visibilidade" "Visible" = "Visível" "Vivid" = "Vívido" +"Votes per person" = "Votos por pessoa" +"Voting session" = "Sessão de votação" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica a API gráfica a usar; sem ela, o FigCraft escolhe sozinho." "Warm" = "Quente" "Watch" = "Relógio" @@ -1449,6 +1473,7 @@ status = "draft — needs native review" "WebSocket URL" = "URL do WebSocket" "WebSocket URL copied" = "URL do WebSocket copiada" "Wedge" = "Cunha" +"Weekly planner" = "Planejador semanal" "Weight" = "Espessura" "What changed since a saved version" = "O que mudou desde uma versão salva" "What changed since it was marked ready for dev" = "O que mudou desde que foi marcado como pronto para dev" @@ -1652,6 +1677,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} camada selecionada", other = "{n} camadas selecionadas" } "{n} layers · {parts}" = { one = "{n} camada · {parts}", other = "{n} camadas · {parts}" } "{n} matches" = { one = "{n} resultado", other = "{n} resultados" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "há {n} minuto", other = "há {n} minutos" } "{n} months ago" = { one = "há {n} mês", other = "há {n} meses" } "{n} more notes omitted from this report" = { one = "{n} nota a mais omitida deste relatório", other = "{n} notas a mais omitidas deste relatório" } @@ -1664,6 +1690,8 @@ status = "draft — needs native review" "{n} rows" = { one = "{n} linha", other = "{n} linhas" } "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } "{n} variables" = { one = "{n} variável", other = "{n} variáveis" } +"{n} votes" = { one = "{n} voto", other = "{n} votos" } +"{n} votes left" = { one = "{n} voto restante", other = "{n} votos restantes" } "{n} weeks ago" = { one = "há {n} semana", other = "há {n} semanas" } "{n} years ago" = { one = "há {n} ano", other = "há {n} anos" } "{n}d" = "{n} d" @@ -1840,6 +1868,14 @@ status = "draft — needs native review" "board.shape" = "Forma com texto" "board.stamp" = "Carimbo" "board.sticky" = "Nota adesiva" +"board.table" = "Tabela" +"board.tableEdit" = "Editar tabela" +"board.template" = "Modelo de quadro" +"board.templates" = "Listar modelos de quadro" +"board.timer" = "Timer" +"board.timerState" = "Estado do timer" +"board.vote" = "Sessão de votação" +"board.voteResults" = "Resultados da votação" "bool.exclude" = "Excluir sobreposição da seleção" "bool.flatten" = "Nivelar booleano" "bool.intersect" = "Interseção da seleção" @@ -2386,6 +2422,8 @@ status = "draft — needs native review" "widget.click" = "Clicar no widget" "widget.insert" = "Inserir widget" "widget.list" = "Listar widgets" +"widget.menu" = "Menu do widget" +"widget.setText" = "Editar campo do widget" "window.focus" = "Trazer a janela para a frente" "window.new" = "Nova janela" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 1a6b59bc..7d1d1000 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -36,6 +36,7 @@ name = "Русский" "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" = "Сначала добавьте слой или компонент для повторения" +"Add a minute" = "Добавить минуту" "Add a note for developers" = "Добавить заметку для разработчиков" "Add action" = "Добавить действие" "Add actions for when no condition holds" = "Добавить действия на случай, если ни одно условие не выполнено" @@ -43,6 +44,7 @@ name = "Русский" "Add another condition" = "Добавить ещё одно условие" "Add auto layout" = "Добавить автолейаут" "Add background behind overlay" = "Добавить фон за оверлеем" +"Add column" = "Добавить столбец" "Add content to this slot" = "Добавить содержимое в этот слот" "Add dev resource" = "Добавить ресурс для разработки" "Add effect" = "Добавить эффект" @@ -62,6 +64,7 @@ name = "Русский" "Add or update collections from a variables JSON" = "Добавить или обновить коллекции из JSON переменных" "Add properties to make instances configurable" = "Добавьте свойства, чтобы настраивать экземпляры" "Add reaction" = "Добавить реакцию" +"Add row" = "Добавить строку" "Add starting point" = "Добавить начальную точку" "Add stroke" = "Добавить обводку" "Add variant" = "Добавить вариант" @@ -91,6 +94,7 @@ name = "Русский" "All comments" = "Все комментарии" "All components" = "Все компоненты" "All fonts" = "Все шрифты" +"All objects" = "Все объекты" "All supported properties" = "Все поддерживаемые свойства" "All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены" "All variables" = "Все переменные" @@ -181,6 +185,7 @@ name = "Русский" "Bottom left radius" = "Радиус нижнего левого угла" "Bottom padding" = "Нижний внутренний отступ" "Bottom right radius" = "Радиус нижнего правого угла" +"Brainstorm" = "Мозговой штурм" "Branch" = "Ветка" "Branch archived" = "Ветка архивирована" "Branch merged" = "Ветка объединена" @@ -211,6 +216,8 @@ name = "Русский" "Checking the server…" = "Проверка сервера…" "Chevron" = "Шеврон" "Clear from this list" = "Убрать из этого списка" +"Clear results" = "Очистить результаты" +"Click objects to vote; click your badge to take a vote back" = "Нажмите на объекты, чтобы проголосовать; нажмите на свой значок, чтобы забрать голос" "Code or invite link" = "Код или ссылка приглашения" "Color profile: {name}" = "Цветовой профиль: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Цветовые и текстовые стили, переменные светлой и тёмной тем, кнопки, поля ввода и карточки" @@ -462,6 +469,7 @@ name = "Русский" "Empty" = "Пусто" "End" = "Конец" "End point" = "Конечная точка" +"End vote" = "Завершить голосование" "Ends the session; everyone keeps their copy" = "Завершает сессию; у всех остаются свои копии" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Введите адрес figcraft-server и нажмите «Обзор», чтобы увидеть доступные плагины." @@ -538,6 +546,7 @@ name = "Русский" "Flip text to the other side" = "Перенести текст на другую сторону" "Flip vertical" = "Отразить по вертикали" "Flip width profile" = "Отразить профиль ширины" +"Flow chart" = "Блок-схема" "Flow starting point" = "Начальная точка сценария" "Flow starting points" = "Начальные точки сценариев" "Flows" = "Сценарии" @@ -694,6 +703,7 @@ name = "Русский" "Jun" = "июн." "Just now" = "Только что" "Justify" = "По ширине" +"Kanban board" = "Канбан-доска" "Keep a measurement on the canvas for developers" = "Оставить измерение на холсте для разработчиков" "Keep appearance" = "Сохранить внешний вид" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "«Сохранить внешний вид» преобразует все значения цветов в {profile}, сохраняя их вид. «Сохранить значения» оставляет числа без изменений, поэтому цвета меняются." @@ -794,6 +804,7 @@ name = "Русский" "Min H" = "Мин. В" "Min W" = "Мин. Ш" "Minimize" = "Свернуть окно" +"Minutes" = "Минуты" "Mirror angle" = "Отражать угол" "Mirror angle and length" = "Отражать угол и длину" "Mirroring" = "Зеркальность" @@ -891,6 +902,7 @@ name = "Русский" "No text styles yet. Click + to create one from the selected text." = "Текстовых стилей пока нет. Нажмите +, чтобы создать стиль из выделенного текста." "No variables fit here." = "Нет подходящих переменных." "No variables yet. Create them in Local variables." = "Переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No votes yet" = "Голосов пока нет" "Nobody else is here yet" = "Здесь пока больше никого нет" "Noise" = "Шум" "None" = "Нет" @@ -979,6 +991,7 @@ name = "Русский" "Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" +"Pause" = "Пауза" "Pink" = "Розовый" "Placed on the current page" = "Размещено на текущей странице" "Pages: {pages}" = "Страницы: {pages}" @@ -1096,6 +1109,7 @@ name = "Русский" "Remove all" = "Удалить всё" "Remove annotation" = "Удалить аннотацию" "Remove auto layout" = "Убрать автолейаут" +"Remove column" = "Удалить столбец" "Remove dev resource" = "Удалить ресурс для разработки" "Remove export setting" = "Удалить настройку экспорта" "Remove from recents" = "Убрать из недавних" @@ -1109,6 +1123,7 @@ name = "Русский" "Remove min and max height" = "Убрать минимальную и максимальную высоту" "Remove min and max width" = "Убрать минимальную и максимальную ширину" "Remove regions" = "Удалить области" +"Remove row" = "Удалить строку" "Remove slot" = "Убрать слот" "Remove starting point" = "Убрать начальную точку" "Remove stop" = "Удалить точку градиента" @@ -1162,6 +1177,9 @@ name = "Русский" "Restore main component" = "Восстановить основной компонент" "Restore this version" = "Восстановить эту версию" "Restored “{name}”" = "«{name}» восстановлено" +"Retrospective" = "Ретроспектива" +"Results" = "Результаты" +"Resume" = "Продолжить" "Reverse the order" = "Обратить порядок" "Reversed triangle" = "Обратный треугольник" "Review and merge" = "Проверить и объединить" @@ -1343,17 +1361,20 @@ name = "Русский" "Start point" = "Начальная точка" "Start sharing" = "Включить общий доступ" "Start sharing with the Share button, then pass on the code it gives" = "Включите общий доступ кнопкой «Поделиться», затем передайте полученный код" +"Start vote" = "Начать голосование" "Starting to share…" = "Включение общего доступа…" "Status" = "Статус" "Sticky" = "Закреплённый" "Sticky note" = "Стикер" "Sticky note colors" = "Цвета стикеров" +"Sticky notes" = "Стикеры" "Sticky notes, shapes and connectors" = "Стикеры, фигуры и соединители" "Stop" = "Остановить" "Stop following {name}" = "Перестать следить за {name}" "Stop session" = "Завершить сессию" "Stop sharing" = "Остановить общий доступ" "Stop spotlighting" = "Прекратить показ всем" +"Stop timer" = "Остановить таймер" "Stop {n} {hex}" = "Точка {n} {hex}" "Straight" = "Прямая" "Stretch" = "Растянуть" @@ -1441,6 +1462,7 @@ name = "Русский" "Tidy up" = "Упорядочить" "Tile" = "Повторение" "Tile type" = "Тип повторения" +"Timer" = "Таймер" "Tint" = "Оттенок" "Title" = "Заголовок" "Title case" = "Каждое Слово С Прописной" @@ -1538,6 +1560,8 @@ name = "Русский" "Visibility" = "Видимость" "Visible" = "Видимый" "Vivid" = "Яркий" +"Votes per person" = "Голосов на человека" +"Voting session" = "Голосование" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Переменная WGPU_BACKEND задана и указывает используемый графический API; если её убрать, FigCraft выберет сам." "Warm" = "Тёплый" "Watch" = "Часы" @@ -1547,6 +1571,7 @@ name = "Русский" "WebSocket URL" = "URL WebSocket" "WebSocket URL copied" = "URL WebSocket скопирован" "Wedge" = "Клин" +"Weekly planner" = "План на неделю" "Weight" = "Толщина" "What you try here changes this preview and the code below, not the file" = "Здесь меняются только предпросмотр и код ниже; файл остаётся без изменений" "What changed since it was marked ready for dev" = "Изменения после отметки «Готово к разработке»" @@ -1757,6 +1782,7 @@ name = "Русский" "{n} layers selected" = { one = "Выделен {n} слой", few = "Выделены {n} слоя", many = "Выделено {n} слоёв", other = "Выделено {n} слоя" } "{n} layers · {parts}" = { one = "{n} слой · {parts}", few = "{n} слоя · {parts}", many = "{n} слоёв · {parts}", other = "{n} слоя · {parts}" } "{n} matches" = { one = "{n} совпадение", few = "{n} совпадения", many = "{n} совпадений", other = "{n} совпадения" } +"{n} min" = "{n} мин" "{n} minutes ago" = { one = "{n} минуту назад", few = "{n} минуты назад", many = "{n} минут назад", other = "{n} минуты назад" } "{n} months ago" = { one = "{n} месяц назад", few = "{n} месяца назад", many = "{n} месяцев назад", other = "{n} месяца назад" } "{n} more notes omitted from this report" = { one = "Ещё {n} замечание не включено в этот отчёт", few = "Ещё {n} замечания не включены в этот отчёт", many = "Ещё {n} замечаний не включено в этот отчёт", other = "Ещё {n} замечания не включено в этот отчёт" } @@ -1770,6 +1796,8 @@ name = "Русский" "{n} styles" = { one = "{n} стиль", few = "{n} стиля", many = "{n} стилей", other = "{n} стиля" } "{n} text layers" = { one = "{n} текстовый слой", few = "{n} текстовых слоя", many = "{n} текстовых слоёв", other = "{n} текстовых слоя" } "{n} variables" = { one = "{n} переменная", few = "{n} переменные", many = "{n} переменных", other = "{n} переменной" } +"{n} votes" = { one = "{n} голос", few = "{n} голоса", many = "{n} голосов", other = "{n} голоса" } +"{n} votes left" = { one = "Остался {n} голос", few = "Осталось {n} голоса", many = "Осталось {n} голосов", other = "Осталось {n} голоса" } "{n} weeks ago" = { one = "{n} неделю назад", few = "{n} недели назад", many = "{n} недель назад", other = "{n} недели назад" } "{n} years ago" = { one = "{n} год назад", few = "{n} года назад", many = "{n} лет назад", other = "{n} года назад" } "{n}d" = "{n} д" @@ -1834,6 +1862,14 @@ name = "Русский" "board.shape" = "Фигура с текстом" "board.stamp" = "Штамп" "board.sticky" = "Стикер" +"board.table" = "Таблица" +"board.tableEdit" = "Изменить таблицу" +"board.template" = "Шаблон доски" +"board.templates" = "Список шаблонов доски" +"board.timer" = "Таймер" +"board.timerState" = "Состояние таймера" +"board.vote" = "Голосование" +"board.voteResults" = "Результаты голосования" "bool.exclude" = "Исключить выделение" "bool.flatten" = "Свести булеву группу" "bool.intersect" = "Пересечь выделение" @@ -2394,6 +2430,8 @@ name = "Русский" "widget.click" = "Нажать виджет" "widget.insert" = "Вставить виджет" "widget.list" = "Список виджетов" +"widget.menu" = "Меню виджета" +"widget.setText" = "Изменить поле виджета" "window.focus" = "Вывести окно на передний план" "window.new" = "Новое окно" "template.list" = "Список шаблонов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 7624674d..0dfbb2fa 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -36,6 +36,7 @@ name = "Svenska" "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" +"Add a minute" = "Lägg till en minut" "Add a note for developers" = "Lägg till en anteckning för utvecklare" "Add action" = "Lägg till åtgärd" "Add actions for when no condition holds" = "Lägg till åtgärder för när inget villkor uppfylls" @@ -43,6 +44,7 @@ name = "Svenska" "Add another condition" = "Lägg till ett villkor till" "Add auto layout" = "Lägg till auto layout" "Add background behind overlay" = "Lägg till bakgrund bakom överlägg" +"Add column" = "Lägg till kolumn" "Add content to this slot" = "Lägg till innehåll i facket" "Add dev resource" = "Lägg till utvecklarresurs" "Add effect" = "Lägg till effekt" @@ -62,6 +64,7 @@ name = "Svenska" "Add or update collections from a variables JSON" = "Lägg till eller uppdatera samlingar från en variabel-JSON" "Add properties to make instances configurable" = "Lägg till egenskaper för att göra instanser konfigurerbara" "Add reaction" = "Lägg till reaktion" +"Add row" = "Lägg till rad" "Add starting point" = "Lägg till startpunkt" "Add stroke" = "Lägg till kontur" "Add variant" = "Lägg till variant" @@ -91,6 +94,7 @@ name = "Svenska" "All comments" = "Alla kommentarer" "All components" = "Alla komponenter" "All fonts" = "Alla typsnitt" +"All objects" = "Alla objekt" "All supported properties" = "Alla egenskaper som stöds" "All the fonts this file uses are installed" = "Alla typsnitt som filen använder är installerade" "All variables" = "Alla variabler" @@ -181,6 +185,7 @@ name = "Svenska" "Bottom left radius" = "Radie nere till vänster" "Bottom padding" = "Nedre padding" "Bottom right radius" = "Radie nere till höger" +"Brainstorm" = "Idéstorm" "Branch" = "Gren" "Branch archived" = "Grenen har arkiverats" "Branch merged" = "Grenen har sammanfogats" @@ -211,6 +216,8 @@ name = "Svenska" "Checking the server…" = "Kontrollerar servern…" "Chevron" = "Vinkel" "Clear from this list" = "Ta bort från listan" +"Clear results" = "Rensa resultat" +"Click objects to vote; click your badge to take a vote back" = "Klicka på objekt för att rösta; klicka på din bricka för att ta tillbaka en röst" "Code or invite link" = "Kod eller inbjudningslänk" "Color profile: {name}" = "Färgprofil: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Färg- och textstilar, ljusa och mörka variabler, knappar, inmatningsfält och kort" @@ -462,6 +469,7 @@ name = "Svenska" "Empty" = "Tom" "End" = "Slut" "End point" = "Slutpunkt" +"End vote" = "Avsluta omröstning" "Ends the session; everyone keeps their copy" = "Avslutar sessionen; alla behåller sin kopia" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Ange adressen till en figcraft-server och tryck på Bläddra för att se vilka plugins den erbjuder." @@ -538,6 +546,7 @@ name = "Svenska" "Flip text to the other side" = "Vänd texten till andra sidan" "Flip vertical" = "Vänd lodrätt" "Flip width profile" = "Vänd breddprofil" +"Flow chart" = "Flödesschema" "Flow starting point" = "Startpunkt för flöde" "Flow starting points" = "Startpunkter för flöden" "Flows" = "Flöden" @@ -694,6 +703,7 @@ name = "Svenska" "Jun" = "jun" "Just now" = "Nyss" "Justify" = "Marginaljustera" +"Kanban board" = "Kanbantavla" "Keep a measurement on the canvas for developers" = "Behåll ett mått på arbetsytan för utvecklare" "Keep appearance" = "Behåll utseende" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Behåll utseende konverterar varje färgvärde till {profile} så att färgerna ser likadana ut. Behåll värden lämnar siffrorna som de är, så färgerna förskjuts." @@ -794,6 +804,7 @@ name = "Svenska" "Min H" = "Min H" "Min W" = "Min B" "Minimize" = "Minimera" +"Minutes" = "Minuter" "Mirror angle" = "Spegla vinkel" "Mirror angle and length" = "Spegla vinkel och längd" "Mirroring" = "Spegling" @@ -891,6 +902,7 @@ name = "Svenska" "No text styles yet. Click + to create one from the selected text." = "Inga textstilar än. Klicka på + för att skapa en från den markerade texten." "No variables fit here." = "Inga variabler passar här." "No variables yet. Create them in Local variables." = "Inga variabler än. Skapa dem i Lokala variabler." +"No votes yet" = "Inga röster än" "Nobody else is here yet" = "Ingen annan är här än" "Noise" = "Brus" "None" = "Ingen" @@ -979,6 +991,7 @@ name = "Svenska" "Page deleted" = "Sidan har raderats" "Pages" = "Sidor" "Pages added to the document" = "Sidor tillagda i dokumentet" +"Pause" = "Pausa" "Pink" = "Rosa" "Placed on the current page" = "Placerat på den aktuella sidan" "Pages: {pages}" = "Sidor: {pages}" @@ -1096,6 +1109,7 @@ name = "Svenska" "Remove all" = "Ta bort alla" "Remove annotation" = "Ta bort anteckning" "Remove auto layout" = "Ta bort auto layout" +"Remove column" = "Ta bort kolumn" "Remove dev resource" = "Ta bort utvecklarresurs" "Remove export setting" = "Ta bort exportinställning" "Remove from recents" = "Ta bort från Senaste" @@ -1109,6 +1123,7 @@ name = "Svenska" "Remove min and max height" = "Ta bort min- och maxhöjd" "Remove min and max width" = "Ta bort min- och maxbredd" "Remove regions" = "Ta bort områden" +"Remove row" = "Ta bort rad" "Remove slot" = "Ta bort plats" "Remove starting point" = "Ta bort startpunkt" "Remove stop" = "Ta bort stopp" @@ -1162,6 +1177,9 @@ name = "Svenska" "Restore main component" = "Återställ huvudkomponent" "Restore this version" = "Återställ den här versionen" "Restored “{name}”" = "Återställde ”{name}”" +"Retrospective" = "Retrospektiv" +"Results" = "Resultat" +"Resume" = "Återuppta" "Reverse the order" = "Vänd på ordningen" "Reversed triangle" = "Omvänd triangel" "Review and merge" = "Granska och sammanfoga" @@ -1343,17 +1361,20 @@ name = "Svenska" "Start point" = "Startpunkt" "Start sharing" = "Börja dela" "Start sharing with the Share button, then pass on the code it gives" = "Börja dela med knappen Dela och skicka sedan vidare koden du får" +"Start vote" = "Starta omröstning" "Starting to share…" = "Börjar dela…" "Status" = "Status" "Sticky" = "Fast" "Sticky note" = "Lapp" "Sticky note colors" = "Lappfärger" +"Sticky notes" = "Lappar" "Sticky notes, shapes and connectors" = "Lappar, former och kopplingar" "Stop" = "Stoppa" "Stop following {name}" = "Sluta följa {name}" "Stop session" = "Avsluta session" "Stop sharing" = "Sluta dela" "Stop spotlighting" = "Sluta använda strålkastaren" +"Stop timer" = "Stoppa timern" "Stop {n} {hex}" = "Stopp {n} {hex}" "Straight" = "Rak" "Stretch" = "Sträck" @@ -1441,6 +1462,7 @@ name = "Svenska" "Tidy up" = "Snygga till" "Tile" = "Sida vid sida" "Tile type" = "Upprepningstyp" +"Timer" = "Timer" "Tint" = "Färgton" "Title" = "Titel" "Title case" = "Inledande versaler" @@ -1538,6 +1560,8 @@ name = "Svenska" "Visibility" = "Synlighet" "Visible" = "Synlig" "Vivid" = "Livfull" +"Votes per person" = "Röster per person" +"Voting session" = "Omröstning" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND är satt och anger vilket grafik-API som ska användas; tar du bort den väljer FigCraft själv." "Warm" = "Varm" "Watch" = "Bevaka" @@ -1547,6 +1571,7 @@ name = "Svenska" "WebSocket URL" = "WebSocket-URL" "WebSocket URL copied" = "WebSocket-URL kopierad" "Wedge" = "Kil" +"Weekly planner" = "Veckoplanering" "Weight" = "Tjocklek" "What you try here changes this preview and the code below, not the file" = "Det du provar här ändrar den här förhandsgranskningen och koden nedan, inte filen" "What changed since it was marked ready for dev" = "Vad som har ändrats sedan den markerades som klar för utveckling" @@ -1757,6 +1782,7 @@ name = "Svenska" "{n} layers selected" = { one = "{n} lager markerat", other = "{n} lager markerade" } "{n} layers · {parts}" = { one = "{n} lager · {parts}", other = "{n} lager · {parts}" } "{n} matches" = { one = "{n} träff", other = "{n} träffar" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "för {n} minut sedan", other = "för {n} minuter sedan" } "{n} months ago" = { one = "för {n} månad sedan", other = "för {n} månader sedan" } "{n} more notes omitted from this report" = { one = "{n} anteckning till utelämnades i den här rapporten", other = "{n} anteckningar till utelämnades i den här rapporten" } @@ -1770,6 +1796,8 @@ name = "Svenska" "{n} styles" = { one = "{n} formatmall", other = "{n} formatmallar" } "{n} text layers" = { one = "{n} textlager", other = "{n} textlager" } "{n} variables" = { one = "{n} variabel", other = "{n} variabler" } +"{n} votes" = { one = "{n} röst", other = "{n} röster" } +"{n} votes left" = { one = "{n} röst kvar", other = "{n} röster kvar" } "{n} weeks ago" = { one = "för {n} vecka sedan", other = "för {n} veckor sedan" } "{n} years ago" = { one = "för {n} år sedan", other = "för {n} år sedan" } "{n}d" = "{n} d" @@ -1835,6 +1863,14 @@ name = "Svenska" "board.shape" = "Form med text" "board.stamp" = "Stämpel" "board.sticky" = "Lapp" +"board.table" = "Tabell" +"board.tableEdit" = "Redigera tabell" +"board.template" = "Whiteboardmall" +"board.templates" = "Lista whiteboardmallar" +"board.timer" = "Timer" +"board.timerState" = "Timerstatus" +"board.vote" = "Omröstning" +"board.voteResults" = "Omröstningsresultat" "bool.exclude" = "Uteslut markering" "bool.flatten" = "Platta till boolesk form" "bool.intersect" = "Skärning av markering" @@ -2396,6 +2432,8 @@ name = "Svenska" "widget.click" = "Klicka på widget" "widget.insert" = "Infoga widget" "widget.list" = "Lista widgetar" +"widget.menu" = "Widgetmeny" +"widget.setText" = "Redigera widgetfält" "window.focus" = "Placera fönstret längst fram" "window.new" = "Nytt fönster" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index ed7640ad..58166c93 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -37,6 +37,7 @@ name = "Türkçe" "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" +"Add a minute" = "Bir dakika ekle" "Add a note for developers" = "Geliştiriciler için not ekle" "Add action" = "Eylem ekle" "Add actions for when no condition holds" = "Hiçbir koşul sağlanmadığında yapılacak eylemleri ekleyin" @@ -44,6 +45,7 @@ name = "Türkçe" "Add another condition" = "Başka bir koşul ekle" "Add auto layout" = "Otomatik yerleşim ekle" "Add background behind overlay" = "Bindirmenin arkasına arka plan ekle" +"Add column" = "Sütun ekle" "Add content to this slot" = "Bu yuvaya içerik ekle" "Add dev resource" = "Geliştirici kaynağı ekle" "Add effect" = "Efekt ekle" @@ -63,6 +65,7 @@ name = "Türkçe" "Add or update collections from a variables JSON" = "Bir değişken JSON'undan koleksiyon ekleyin veya güncelleyin" "Add properties to make instances configurable" = "Örnekleri yapılandırılabilir kılmak için özellik ekleyin" "Add reaction" = "Tepki ekle" +"Add row" = "Satır ekle" "Add starting point" = "Başlangıç noktası ekle" "Add stroke" = "Kontur ekle" "Add variant" = "Varyant ekle" @@ -91,6 +94,7 @@ name = "Türkçe" "All comments" = "Tüm yorumlar" "All components" = "Tüm bileşenler" "All fonts" = "Tüm yazı tipleri" +"All objects" = "Tüm nesneler" "All supported properties" = "Desteklenen tüm özellikler" "All variables" = "Tüm değişkenler" "Along the bottom" = "Alt kenar boyunca" @@ -179,6 +183,7 @@ name = "Türkçe" "Bottom" = "Alt" "Bottom left radius" = "Sol alt yarıçap" "Bottom right radius" = "Sağ alt yarıçap" +"Brainstorm" = "Beyin fırtınası" "Branch" = "Dal" "Branch archived" = "Dal arşivlendi" "Branch merged" = "Dal birleştirildi" @@ -208,6 +213,8 @@ name = "Türkçe" "Checking the server…" = "Sunucu denetleniyor…" "Chevron" = "Şerit ok" "Clear from this list" = "Bu listeden kaldır" +"Clear results" = "Sonuçları temizle" +"Click objects to vote; click your badge to take a vote back" = "Oy vermek için nesnelere tıklayın; oyu geri almak için rozetinize tıklayın" "Code or invite link" = "Kod veya davet bağlantısı" "Color profile: {name}" = "Renk profili: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Renk ve metin stilleri, Açık ve Koyu değişkenler, düğmeler, giriş alanları ve kartlar" @@ -452,6 +459,7 @@ name = "Türkçe" "Empty" = "Boş" "End" = "Bitiş" "End point" = "Bitiş noktası" +"End vote" = "Oylamayı bitir" "Ends the session; everyone keeps their copy" = "Oturumu sonlandırır; herkes kendi kopyasını saklar" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Bir figcraft-server adresi girin ve sunduğu eklentileri görmek için Gözat'a basın." @@ -527,6 +535,7 @@ name = "Türkçe" "Flip text to the other side" = "Metni diğer tarafa çevir" "Flip vertical" = "Dikey çevir" "Flip width profile" = "Genişlik profilini çevir" +"Flow chart" = "Akış şeması" "Flow starting point" = "Akış başlangıç noktası" "Flow starting points" = "Akış başlangıç noktaları" "Flows" = "Akışlar" @@ -680,6 +689,7 @@ name = "Türkçe" "Jun" = "Haz" "Just now" = "Az önce" "Justify" = "İki yana yasla" +"Kanban board" = "Kanban panosu" "Keep a measurement on the canvas for developers" = "Geliştiriciler için tuvalde bir ölçüm tut" "Keep appearance" = "Görünümü koru" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Görünümü koru, renkler aynı görünsün diye her renk değerini {profile} profiline dönüştürür. Değerleri koru, sayıları olduğu gibi bırakır; bu yüzden renkler kayar." @@ -779,6 +789,7 @@ name = "Türkçe" "Min H" = "En küçük Y" "Min W" = "En küçük G" "Minimize" = "Simge durumuna küçült" +"Minutes" = "Dakika" "Mirror angle" = "Açıyı yansıt" "Mirror angle and length" = "Açıyı ve uzunluğu yansıt" "Mirroring" = "Yansıtma" @@ -873,6 +884,7 @@ name = "Türkçe" "No text styles yet. Click + to create one from the selected text." = "Henüz metin stili yok. Seçili metinden oluşturmak için + simgesine tıklayın." "No variables fit here." = "Buraya uyan değişken yok." "No variables yet. Create them in Local variables." = "Henüz değişken yok. Onları Yerel değişkenler'de oluşturun." +"No votes yet" = "Henüz oy yok" "Nobody else is here yet" = "Burada henüz başka kimse yok" "Noise" = "Gürültü" "None" = "Yok" @@ -958,6 +970,7 @@ name = "Türkçe" "Page deleted" = "Sayfa silindi" "Pages" = "Sayfalar" "Pages added to the document" = "Sayfalar belgeye eklendi" +"Pause" = "Duraklat" "Pink" = "Pembe" "Placed on the current page" = "Geçerli sayfaya yerleştirildi" "Pages: {pages}" = "Sayfalar: {pages}" @@ -1072,6 +1085,7 @@ name = "Türkçe" "Remove all" = "Tümünü kaldır" "Remove annotation" = "Ek açıklamayı kaldır" "Remove auto layout" = "Otomatik yerleşimi kaldır" +"Remove column" = "Sütunu kaldır" "Remove dev resource" = "Geliştirici kaynağını kaldır" "Remove export setting" = "Dışa aktarma ayarını kaldır" "Remove from recents" = "Son kullanılanlardan kaldır" @@ -1085,6 +1099,7 @@ name = "Türkçe" "Remove min and max height" = "En küçük ve en büyük yüksekliği kaldır" "Remove min and max width" = "En küçük ve en büyük genişliği kaldır" "Remove regions" = "Bölgeleri kaldır" +"Remove row" = "Satırı kaldır" "Remove slot" = "Yuvayı kaldır" "Remove starting point" = "Başlangıç noktasını kaldır" "Remove stop" = "Durağı kaldır" @@ -1136,6 +1151,9 @@ name = "Türkçe" "Restore main component" = "Ana bileşeni geri yükle" "Restore this version" = "Bu sürümü geri yükle" "Restored “{name}”" = "“{name}” geri yüklendi" +"Retrospective" = "Retrospektif" +"Results" = "Sonuçlar" +"Resume" = "Sürdür" "Reverse the order" = "Sırayı ters çevir" "Reversed triangle" = "Ters üçgen" "Review and merge" = "Gözden geçir ve birleştir" @@ -1311,17 +1329,20 @@ name = "Türkçe" "Start point" = "Başlangıç noktası" "Start sharing" = "Paylaşmaya başla" "Start sharing with the Share button, then pass on the code it gives" = "Paylaş düğmesiyle paylaşmaya başlayın, sonra verdiği kodu iletin" +"Start vote" = "Oylamayı başlat" "Starting to share…" = "Paylaşım başlatılıyor…" "Status" = "Durum" "Sticky" = "Yapışkan" "Sticky note" = "Yapışkan not" "Sticky note colors" = "Yapışkan not renkleri" +"Sticky notes" = "Yapışkan notlar" "Sticky notes, shapes and connectors" = "Yapışkan notlar, şekiller ve bağlayıcılar" "Stop" = "Durdur" "Stop following {name}" = "{name} takibini bırak" "Stop session" = "Oturumu durdur" "Stop sharing" = "Paylaşmayı durdur" "Stop spotlighting" = "Öne çıkarmayı durdur" +"Stop timer" = "Zamanlayıcıyı durdur" "Stop {n} {hex}" = "Durak {n} {hex}" "Straight" = "Düz" "Stretch" = "Uzat" @@ -1403,6 +1424,7 @@ name = "Türkçe" "Tidy up" = "Toparla" "Tile" = "Döşe" "Tile type" = "Döşeme türü" +"Timer" = "Zamanlayıcı" "Tint" = "Renk tonu" "Title" = "Başlık" "Title case" = "Başlık biçimi" @@ -1495,6 +1517,8 @@ name = "Türkçe" "Visibility" = "Görünürlük" "Visible" = "Görünür" "Vivid" = "Canlı" +"Votes per person" = "Kişi başı oy" +"Voting session" = "Oylama oturumu" "Warm" = "Sıcak" "Watch" = "İzle" "Wavy" = "Dalgalı" @@ -1503,6 +1527,7 @@ name = "Türkçe" "WebSocket URL" = "WebSocket URL'si" "WebSocket URL copied" = "WebSocket URL'si kopyalandı" "Wedge" = "Kama" +"Weekly planner" = "Haftalık planlayıcı" "Weight" = "Kalınlık" "What you try here changes this preview and the code below, not the file" = "Burada denedikleriniz dosyayı değil, bu önizlemeyi ve aşağıdaki kodu değiştirir" "What changed since it was marked ready for dev" = "Geliştirmeye hazır olarak işaretlendiğinden beri neler değişti" @@ -1710,6 +1735,7 @@ name = "Türkçe" "{n} layers selected" = { other = "{n} katman seçili" } "{n} layers · {parts}" = { other = "{n} katman · {parts}" } "{n} matches" = { other = "{n} eşleşme" } +"{n} min" = "{n} dk" "{n} minutes ago" = { other = "{n} dakika önce" } "{n} months ago" = { other = "{n} ay önce" } "{n} more notes omitted from this report" = { other = "{n} not daha bu rapora alınmadı" } @@ -1722,6 +1748,8 @@ name = "Türkçe" "{n} rows" = { other = "{n} satır" } "{n} styles" = { other = "{n} stil" } "{n} variables" = { other = "{n} değişken" } +"{n} votes" = { other = "{n} oy" } +"{n} votes left" = { other = "{n} oy kaldı" } "{n} weeks ago" = { other = "{n} hafta önce" } "{n} years ago" = { other = "{n} yıl önce" } "{n}d" = "{n} g" @@ -1784,6 +1812,14 @@ name = "Türkçe" "board.shape" = "Metinli şekil" "board.stamp" = "Damga" "board.sticky" = "Yapışkan not" +"board.table" = "Tablo" +"board.tableEdit" = "Tabloyu düzenle" +"board.template" = "Pano şablonu" +"board.templates" = "Pano şablonlarını listele" +"board.timer" = "Zamanlayıcı" +"board.timerState" = "Zamanlayıcı durumu" +"board.vote" = "Oylama oturumu" +"board.voteResults" = "Oylama sonuçları" "bool.exclude" = "Seçimi hariç tut" "bool.flatten" = "Boolean'ı düzleştir" "bool.intersect" = "Seçimi kesiştir" @@ -2335,6 +2371,8 @@ name = "Türkçe" "widget.click" = "Widget'a tıkla" "widget.insert" = "Widget ekle" "widget.list" = "Widget'ları listele" +"widget.menu" = "Widget menüsü" +"widget.setText" = "Widget alanını düzenle" "window.focus" = "Pencereyi öne getir" "window.new" = "Yeni pencere" "template.list" = "Şablonları listele" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 82efa9c9..a3c5c92e 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -36,6 +36,7 @@ status = "draft — needs native review" "Add a description" = "添加描述" "Add a frame (F) or a shape (R) to get started" = "添加画框(F)或形状(R)以开始" "Add a layer or component to tile first" = "请先添加要平铺的图层或组件" +"Add a minute" = "加一分钟" "Add a note for developers" = "为开发者添加备注" "Add action" = "添加操作" "Add actions for when no condition holds" = "添加所有条件都不满足时的操作" @@ -43,6 +44,7 @@ status = "draft — needs native review" "Add another condition" = "添加其他条件" "Add auto layout" = "添加自动布局" "Add background behind overlay" = "在浮层后添加背景" +"Add column" = "添加列" "Add content to this slot" = "向此插槽添加内容" "Add dev resource" = "添加开发资源" "Add effect" = "添加效果" @@ -62,6 +64,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "从变量 JSON 添加或更新集合" "Add properties to make instances configurable" = "添加属性,使实例可配置" "Add reaction" = "添加表情回应" +"Add row" = "添加行" "Add starting point" = "添加起点" "Add stroke" = "添加描边" "Add variant" = "添加变体" @@ -90,6 +93,7 @@ status = "draft — needs native review" "All comments" = "全部评论" "All components" = "全部组件" "All fonts" = "全部字体" +"All objects" = "所有对象" "All supported properties" = "所有支持的属性" "All variables" = "全部变量" "Along the bottom" = "沿底边" @@ -173,6 +177,7 @@ status = "draft — needs native review" "Bottom" = "底部" "Bottom left radius" = "左下圆角" "Bottom right radius" = "右下圆角" +"Brainstorm" = "头脑风暴" "Branch" = "分支" "Branch archived" = "分支已归档" "Branch merged" = "分支已合并" @@ -201,6 +206,8 @@ status = "draft — needs native review" "Checking the server…" = "正在检查服务器…" "Chevron" = "箭头形" "Clear from this list" = "从此列表中清除" +"Clear results" = "清除结果" +"Click objects to vote; click your badge to take a vote back" = "点击对象投票;点击你的徽章收回一票" "Code or invite link" = "代码或邀请链接" "Color profile: {name}" = "颜色配置文件:{name}" "CSS custom properties…" = "CSS 自定义属性…" @@ -443,6 +450,7 @@ status = "draft — needs native review" "Empty" = "空" "End" = "末尾" "End point" = "终点" +"End vote" = "结束投票" "Ends the session; everyone keeps their copy" = "结束会话;每个人保留自己的副本" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "输入 figcraft-server 的地址,然后按“浏览”查看它提供的插件。" @@ -516,6 +524,7 @@ status = "draft — needs native review" "Flip text to the other side" = "将文本翻转到另一侧" "Flip vertical" = "垂直翻转" "Flip width profile" = "翻转宽度配置" +"Flow chart" = "流程图" "Flow starting point" = "流程起点" "Flow starting points" = "流程起点" "Flows" = "流程" @@ -661,6 +670,7 @@ status = "draft — needs native review" "Join|stroke" = "转角" "Just now" = "刚刚" "Justify" = "两端对齐" +"Kanban board" = "看板" "Keep a measurement on the canvas for developers" = "为开发者在画布上保留测量" "Keep appearance" = "保持外观" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "“保持外观”会将每个颜色值转换为 {profile},使颜色看起来不变。“保持数值”则保留数值不变,颜色会有偏移。" @@ -753,6 +763,7 @@ status = "draft — needs native review" "Min H" = "最小高" "Min W" = "最小宽" "Minimize" = "最小化" +"Minutes" = "分钟" "Mirror angle" = "镜像角度" "Mirror angle and length" = "镜像角度和长度" "Mirroring" = "镜像" @@ -844,6 +855,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "还没有文本样式。点击 + 从所选文本创建。" "No variables fit here." = "没有适用于此处的变量。" "No variables yet. Create them in Local variables." = "还没有变量。请在“本地变量”中创建。" +"No votes yet" = "暂无投票" "Nobody else is here yet" = "这里还没有其他人" "Noise" = "杂色" "None" = "无" @@ -925,6 +937,7 @@ status = "draft — needs native review" "Page deleted" = "页面已删除" "Pages" = "页面" "Pages added to the document" = "页面已添加到文档" +"Pause" = "暂停" "Pink" = "粉色" "Placed on the current page" = "已放置在当前页面" "Pages: {pages}" = "页面:{pages}" @@ -1039,6 +1052,7 @@ status = "draft — needs native review" "Remove all" = "全部移除" "Remove annotation" = "移除标注" "Remove auto layout" = "移除自动布局" +"Remove column" = "删除列" "Remove dev resource" = "移除开发资源" "Remove export setting" = "移除导出设置" "Remove from server" = "从服务器移除" @@ -1051,6 +1065,7 @@ status = "draft — needs native review" "Remove min and max height" = "移除最小和最大高度" "Remove min and max width" = "移除最小和最大宽度" "Remove regions" = "移除区域" +"Remove row" = "删除行" "Remove slot" = "移除插槽" "Remove starting point" = "移除起点" "Remove stop" = "移除色标" @@ -1104,6 +1119,9 @@ status = "draft — needs native review" "Restore main component" = "恢复主组件" "Restore this version" = "恢复此版本" "Restored “{name}”" = "已恢复“{name}”" +"Retrospective" = "回顾" +"Results" = "结果" +"Resume" = "继续" "Reverse the order" = "反转顺序" "Reversed triangle" = "反向三角形" "Review and merge" = "审阅并合并" @@ -1266,17 +1284,20 @@ status = "draft — needs native review" "Start on the Home screen" = "启动时显示主页" "Start point" = "起点" "Start sharing" = "开始分享" +"Start vote" = "开始投票" "Starting to share…" = "正在开始分享…" "Status" = "状态" "Sticky" = "粘性" "Sticky note" = "便利贴" "Sticky note colors" = "便利贴颜色" +"Sticky notes" = "便利贴" "Sticky notes, shapes and connectors" = "便利贴、形状和连接线" "Stop" = "停止" "Stop following {name}" = "停止跟随 {name}" "Stop session" = "停止会话" "Stop sharing" = "停止分享" "Stop spotlighting" = "停止聚焦" +"Stop timer" = "停止计时器" "Stop {n} {hex}" = "色标 {n} {hex}" "Straight" = "直线" "Stretch" = "拉伸" @@ -1318,6 +1339,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "告诉服务器的运营者它有什么问题" "Temperature" = "色温" "Template" = "模板" +"Templates" = "模板" "Terms" = "条款" "Text" = "文本" "Text align" = "文本对齐" @@ -1359,6 +1381,7 @@ status = "draft — needs native review" "Tidy up" = "整理" "Tile" = "平铺" "Tile type" = "平铺类型" +"Timer" = "计时器" "Tint" = "色调" "Title" = "标题" "Title case" = "首字母大写" @@ -1450,6 +1473,8 @@ status = "draft — needs native review" "Visibility" = "可见性" "Visible" = "可见" "Vivid" = "鲜艳" +"Votes per person" = "每人票数" +"Voting session" = "投票" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "已设置 WGPU_BACKEND,它指定了要使用的图形 API;取消设置后由 FigCraft 自行选择。" "Warm" = "暖调" "Watch" = "手表" @@ -1458,6 +1483,7 @@ status = "draft — needs native review" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "已复制 WebSocket URL" "Wedge" = "楔形" +"Weekly planner" = "每周计划" "Weight" = "粗细" "What changed since a saved version" = "自已保存版本以来的更改" "What changed since it was marked ready for dev" = "自标记为可供开发以来的更改" @@ -1661,6 +1687,7 @@ status = "draft — needs native review" "{n} layers selected" = { other = "已选择 {n} 个图层" } "{n} layers · {parts}" = { other = "{n} 个图层 · {parts}" } "{n} matches" = { other = "{n} 个匹配项" } +"{n} min" = "{n} 分钟" "{n} minutes ago" = { other = "{n} 分钟前" } "{n} months ago" = { other = "{n} 个月前" } "{n} more notes omitted from this report" = { other = "另有 {n} 条说明未列入此报告" } @@ -1673,6 +1700,8 @@ status = "draft — needs native review" "{n} rows" = { other = "{n} 行" } "{n} styles" = { other = "{n} 个样式" } "{n} variables" = { other = "{n} 个变量" } +"{n} votes" = { other = "{n} 票" } +"{n} votes left" = { other = "剩余 {n} 票" } "{n} weeks ago" = { other = "{n} 周前" } "{n} years ago" = { other = "{n} 年前" } "{n}d" = "{n}天" @@ -1735,6 +1764,14 @@ status = "draft — needs native review" "board.shape" = "带文字的形状" "board.stamp" = "印章" "board.sticky" = "便利贴" +"board.table" = "表格" +"board.tableEdit" = "编辑表格" +"board.template" = "白板模板" +"board.templates" = "列出白板模板" +"board.timer" = "计时器" +"board.timerState" = "计时器状态" +"board.vote" = "投票" +"board.voteResults" = "投票结果" "bool.exclude" = "排除所选项" "bool.flatten" = "拼合布尔组" "bool.intersect" = "所选项相交" @@ -2285,6 +2322,8 @@ status = "draft — needs native review" "widget.click" = "点击小组件" "widget.insert" = "插入小组件" "widget.list" = "列出小组件" +"widget.menu" = "小组件菜单" +"widget.setText" = "编辑小组件字段" "window.focus" = "将窗口置于前台" "window.new" = "新建窗口" diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs index 2d58d735..c1a76a4d 100644 --- a/crates/ui-egui/src/board_bar.rs +++ b/crates/ui-egui/src/board_bar.rs @@ -96,6 +96,7 @@ struct Has { shape: Option, text: bool, connector: Option<(Routing, bool)>, + table: bool, } /// What the selected board objects take from the bar (layers that aren't board objects, a section @@ -116,10 +117,11 @@ fn has(app: &FigApp) -> Option { h.connector = Some((c.routing, n.stroke.cap_end != StrokeCap::None)); } Some(BoardItem::Label { .. }) => h.text = true, + Some(BoardItem::Table) => h.table = true, Some(BoardItem::Stamp { .. }) | None => {} } } - (h.color || h.text || h.connector.is_some()).then_some(h) + (h.color || h.text || h.connector.is_some() || h.table).then_some(h) } const CELL: f32 = 28.0; @@ -144,6 +146,9 @@ impl Has { if self.connector.is_some() { w += 4.0 * (CELL + 2.0) + PART_END; } + if self.table { + w += crate::board_picker::TABLE_PART_W; + } w } } @@ -188,6 +193,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if h.text { x = size_part(app, ui, x, cy, &t); } + if h.table { + x = crate::board_picker::table_part(app, ui, x, cy, &t); + } if let Some((routing, arrow)) = h.connector { line_part(app, ui, (routing, arrow), pos2(x, cy), &t); } @@ -274,7 +282,7 @@ fn line_part(app: &mut FigApp, ui: &mut Ui, line: (Routing, bool), at: Pos2, t: } /// A thin separator after `x`; where the next part starts. -fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { +pub(crate) fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { ui.painter().line_segment([pos2(x + 2.0, cy - 10.0), pos2(x + 2.0, cy + 10.0)], Stroke::new(1.0, t.border)); x + 8.0 } diff --git a/crates/ui-egui/src/board_facilitate.rs b/crates/ui-egui/src/board_facilitate.rs new file mode 100644 index 00000000..2c5c66ab --- /dev/null +++ b/crates/ui-egui/src/board_facilitate.rs @@ -0,0 +1,257 @@ +//! Boards in the UI: the timer and voting pill at the top right of a board, and the vote badges on +//! objects. Everything they change goes through `board.timer` and `board.vote`. + +use std::time::Duration; + +use egui::{Align2, Color32, CornerRadius, Rect, RichText, Stroke, Ui, pos2, vec2}; +use figcraft_doc::{BoardTimer, NodeId}; +use figcraft_engine::cmd::facilitate::{self, BADGE_PX}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::{tr, tr_noop, trf, trn}; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular, semibold}; +use crate::widgets; + +const CELL: f32 = 28.0; +/// The presets the timer offers (minutes). +const PRESETS: [u32; 5] = [1, 3, 5, 10, 15]; +/// What a vote can be on, as `board.vote {on}` takes it. +const SCOPES: [(&str, &str); 3] = [("all", tr_noop("All objects")), ("stickies", tr_noop("Sticky notes")), ("selection", tr_noop("Selection"))]; + +/// What the pill shows of the vote: none, running (your votes left) or ended (the results). +enum VoteView { + Off, + Running { left: u32 }, + Ended { results: Vec<(usize, NodeId, String, u32)> }, +} + +fn vote_view(app: &FigApp) -> VoteView { + let Ok(doc) = app.session.doc() else { return VoteView::Off }; + let Some(vote) = &doc.vote else { return VoteView::Off }; + if vote.ended { + let results = facilitate::ranked(doc, vote).into_iter().map(|(rank, id, n)| (rank, id, facilitate::object_name(doc, id), n)).collect(); + return VoteView::Ended { results }; + } + let used: u32 = facilitate::mine(doc, &facilitate::voter(&app.session, &Value::Null)).iter().map(|m| m.1).sum(); + VoteView::Running { left: vote.per_person.saturating_sub(used) } +} + +/// The pill at the top right of a board's canvas: the timer, then the vote. +pub fn show(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { + let Ok(doc) = app.session.doc() else { return }; + if !figcraft_engine::board::is_board(doc) { + return; + } + let timer = doc.timer; + let now = facilitate::now_ms(); + if let Some(left) = timer.filter(|t| matches!(t, BoardTimer::Running { .. })).map(|t| t.left(now)).filter(|l| *l > 0) { + // Wake when the shown second changes; nothing else ticks. + ctx.request_repaint_after(Duration::from_millis(left % 1000 + 5)); + } + let vote = vote_view(app); + let editable = app.session.active_read_only().is_none(); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::unique("board_facilitate")) + .order(egui::Order::Middle) + .pivot(Align2::RIGHT_TOP) + .fixed_pos(pos2(canvas.max.x - 12.0, canvas.min.y + 12.0)) + .show(ctx, |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(10)) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin::same(4)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + timer_part(app, ui, timer, now, editable, &t); + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + vote_part(app, ui, vote, editable, &t); + }); + }); + }); +} + +fn timer_part(app: &mut FigApp, ui: &mut Ui, timer: Option, now: u64, editable: bool, t: &Tokens) { + let Some(timer) = timer else { + let resp = icons::button(ui, Icon::Timer, CELL, false, tr("Timer")); + if editable { + timer_popup(app, &resp); + } + return; + }; + let state = facilitate::timer_state(Some(timer), now); + let color = match state { + "done" => t.danger, + "paused" => t.text_secondary, + _ => t.text, + }; + let (r, _) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover()); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::Timer, color); + // As wide as the same clock in zeros: the pill doesn't jitter as the seconds change. + let text = facilitate::clock(timer.left(now)); + let zeros: String = text.chars().map(|c| if c.is_ascii_digit() { '0' } else { c }).collect(); + let w = ui.painter().layout_no_wrap(zeros, semibold(13.0), color).size().x; + let (r, _) = ui.allocate_exact_size(vec2(w + 2.0, CELL), egui::Sense::hover()); + ui.painter().text(r.left_center(), Align2::LEFT_CENTER, text, semibold(13.0), color); + ui.add_space(2.0); + if !editable { + return; + } + let (icon, tip, action) = match timer { + BoardTimer::Paused { .. } => (Icon::Play, tr("Resume"), "resume"), + BoardTimer::Running { .. } => (Icon::Pause, tr("Pause"), "pause"), + }; + if state != "done" && icons::button(ui, icon, CELL, false, tip).clicked() { + app.run("board.timer", json!({"action": action})); + } + if icons::button(ui, Icon::Plus, CELL, false, tr("Add a minute")).clicked() { + app.run("board.timer", json!({"action": "add", "minutes": 1})); + } + if icons::button(ui, Icon::Close, CELL, false, tr("Stop timer")).clicked() { + app.run("board.timer", json!({"action": "stop"})); + } +} + +/// The timer button's popover: presets and a custom length. +fn timer_popup(app: &mut FigApp, anchor: &egui::Response) { + crate::right::menu_popup(anchor).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 8.0); + ui.add_space(2.0); + ui.label(RichText::new(tr("Timer")).font(semibold(12.0))); + let mut start = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for m in PRESETS { + if widgets::button(ui, &trf("{n} min", &[("n", &m)]), false, true).clicked() { + start = Some(m); + } + } + }); + ui.horizontal(|ui| { + let id = egui::Id::unique("board_timer_minutes"); + let mut minutes: u32 = ui.data(|d| d.get_temp(id)).unwrap_or(5); + widgets::label(ui, tr("Minutes"), Tokens::get(ui.ctx()).text_secondary); + ui.add(egui::DragValue::new(&mut minutes).range(1..=1440).speed(0.25)); + ui.data_mut(|d| d.insert_temp(id, minutes)); + if widgets::button(ui, tr("Start"), true, true).clicked() { + start = Some(minutes); + } + }); + if let Some(minutes) = start { + app.run("board.timer", json!({"action": "start", "minutes": minutes})); + ui.close(); + } + }); +} + +fn vote_part(app: &mut FigApp, ui: &mut Ui, vote: VoteView, editable: bool, t: &Tokens) { + match vote { + VoteView::Off => { + let resp = icons::button(ui, Icon::Vote, CELL, false, tr("Voting session")); + if editable { + vote_popup(app, &resp); + } + } + VoteView::Running { left } => { + let (r, resp) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover()); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::Vote, t.accent_text); + let hint = tr("Click objects to vote; click your badge to take a vote back"); + resp.on_hover_text(hint); + ui.label(RichText::new(trn("{n} votes left", left as usize, &[])).font(regular(12.0)).color(t.text)).on_hover_text(hint); + ui.add_space(4.0); + if editable && widgets::button(ui, tr("End vote"), true, true).clicked() { + app.run("board.vote", json!({"action": "end"})); + } + } + VoteView::Ended { results } => { + let (r, _) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover()); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::Vote, t.accent_text); + let resp = widgets::button(ui, tr("Results"), false, true); + results_popup(app, &resp, &results, editable); + } + } +} + +/// The vote button's popover: votes per person, what to vote on, Start. +fn vote_popup(app: &mut FigApp, anchor: &egui::Response) { + let selected = !app.session.selection().is_empty(); + crate::right::menu_popup(anchor).width(236.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.spacing_mut().item_spacing.y = 6.0; + ui.add_space(2.0); + ui.label(RichText::new(tr("Voting session")).font(semibold(12.0))); + let (votes_id, on_id) = (egui::Id::unique("board_vote_votes"), egui::Id::unique("board_vote_on")); + let mut votes: u32 = ui.data(|d| d.get_temp(votes_id)).unwrap_or(3); + let mut on: usize = ui.data(|d| d.get_temp(on_id)).unwrap_or(0); + if on == 2 && !selected { + on = 0; + } + ui.horizontal(|ui| { + widgets::label(ui, tr("Votes per person"), Tokens::get(ui.ctx()).text_secondary); + ui.add(egui::DragValue::new(&mut votes).range(1..=99)); + }); + for (i, (_, label)) in SCOPES.iter().enumerate() { + // The selection is offered while there is one. + if (i != 2 || selected) && widgets::radio(ui, on == i, tr(label)) { + on = i; + } + } + ui.data_mut(|d| { + d.insert_temp(votes_id, votes); + d.insert_temp(on_id, on); + }); + let scope = SCOPES.get(on).map_or("all", |s| s.0); + if widgets::button(ui, tr("Start vote"), true, true).clicked() { + app.run("board.vote", json!({"action": "start", "votes": votes, "on": scope})); + ui.close(); + } + }); +} + +/// The ended vote's results, most votes first: a click on a row selects that object. +fn results_popup(app: &mut FigApp, anchor: &egui::Response, results: &[(usize, NodeId, String, u32)], editable: bool) { + crate::right::menu_popup(anchor).width(260.0).show(|ui| { + ui.add_space(2.0); + ui.label(RichText::new(tr("Results")).font(semibold(12.0))); + if results.is_empty() { + widgets::label(ui, tr("No votes yet"), Tokens::get(ui.ctx()).text_secondary); + } + for (rank, id, name, n) in results { + let label = format!("{rank}. {}", widgets::elide(ui, name, 160.0)); + if crate::right::menu_item(ui, &label, &trn("{n} votes", *n as usize, &[])) { + app.run("select.set", json!({"ids": [id]})); + } + } + if editable { + ui.add_space(4.0); + if widgets::button(ui, tr("Clear results"), false, true).clicked() { + app.run("board.vote", json!({"action": "clear"})); + ui.close(); + } + } + }); +} + +/// The vote badges on the page: your votes while the vote runs, everyone's once it ends. +pub fn badges(app: &FigApp, painter: &egui::Painter) { + let Ok(ds) = app.session.ds() else { return }; + let doc = &ds.doc; + let Some(vote) = &doc.vote else { return }; + let counts = if vote.ended { vote.tally(doc, &doc.votes) } else { facilitate::mine(doc, &facilitate::voter(&app.session, &Value::Null)) }; + let t = Tokens::get(painter.ctx()); + let r = BADGE_PX as f32; + for (id, n) in counts { + let Some(b) = doc.world_bounds(id).filter(|_| doc.page_of(id) == Some(ds.page)) else { continue }; + let (c, _) = facilitate::badge(b, ds.view.zoom); + let c = app.to_screen(c.x, c.y); + painter.circle_filled(c + vec2(0.0, 1.0), r + 1.0, Color32::from_black_alpha(40)); + painter.circle_filled(c, r, t.accent); + painter.circle_stroke(c, r, Stroke::new(1.5, Color32::WHITE)); + painter.text(c, Align2::CENTER_CENTER, n.to_string(), semibold(11.0), t.accent_on); + } +} diff --git a/crates/ui-egui/src/board_picker.rs b/crates/ui-egui/src/board_picker.rs new file mode 100644 index 00000000..6f7e509a --- /dev/null +++ b/crates/ui-egui/src/board_picker.rs @@ -0,0 +1,162 @@ +//! Boards in the UI, continued: the toolbar's templates picker (a popover of the board templates, +//! each with a picture of itself, and a table) and the table's part of the bar over the selection +//! (add or remove a row or a column). + +use std::collections::HashMap; + +use egui::{Align2, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::board_templates::TEMPLATES; +use serde_json::json; + +use crate::FigApp; +use crate::a11y; +use crate::i18n::tr; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular}; +use crate::toolbar::tool_cell; + +/// The picker's key while it is open (`app.ui.tool_flyout`). +const KEY: &str = "board_templates"; +const CARD: egui::Vec2 = vec2(136.0, 104.0); +const PICTURE_H: f32 = 76.0; +const COLUMNS: usize = 3; +const PAD: f32 = 10.0; +const GAP: f32 = 8.0; +const TITLE_H: f32 = 26.0; + +/// The templates button at `at` (its left edge, the toolbar's middle) and, while open, its picker +/// above the toolbar (whose top is `top`). Where the next button starts. +pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Tokens) -> f32 { + let r = Rect::from_min_size(pos2(at.x, at.y - 16.0), vec2(32.0, 32.0)); + let open = app.ui.tool_flyout == Some(KEY); + let resp = tool_cell(ui, t, r, "board_templates", Icon::Templates, open, tr("Templates")); + if open { + a11y::popup(&mut app.ui.nav, resp.id, area_id()); + } + if resp.clicked() { + app.ui.tool_flyout = if open { None } else { Some(KEY) }; + } + if app.ui.tool_flyout == Some(KEY) { + picker(app, ui, r, top - 8.0, t); + } + r.max.x + 4.0 +} + +fn area_id() -> egui::Id { + egui::Id::unique("board_templates_area") +} + +/// A template's picture: the template built on a board of its own and drawn small. +fn picture(ctx: &egui::Context, id: &str) -> Option { + let doc = figcraft_engine::board_templates::preview(id).ok()?; + let layers = doc.node(doc.first_page()?)?.children.clone(); + let b = layers.iter().filter_map(|l| figcraft_render::export_bounds(&doc, *l)).reduce(|a, b| a.union(b))?; + if !(b.width() > 0.0 && b.height() > 0.0) { + return None; + } + let fit = (f64::from(CARD.x - 16.0) / b.width()).min(f64::from(PICTURE_H - 12.0) / b.height()); + let pix = figcraft_render::render_area(&doc, &layers, b, (fit * f64::from(ctx.pixels_per_point())).min(1.0))?; + let image = crate::render_worker::screen_image(&pix, doc.color_profile); + Some(ctx.load_texture(format!("board-template-{id}"), image, egui::TextureOptions::LINEAR)) +} + +/// The templates' pictures, drawn the first time the picker opens. +#[derive(Clone, Default)] +struct Pictures(HashMap<&'static str, Option>); + +/// The picker: a card for each template and one for a table, its bottom at `bottom`, from the +/// button `anchor`'s right edge leftwards (inside the canvas). +fn picker(app: &mut FigApp, ui: &mut Ui, anchor: Rect, bottom: f32, t: &Tokens) { + let ctx = ui.ctx().clone(); + let mut pictures: Pictures = ctx.data(|d| d.get_temp(area_id())).unwrap_or_default(); + for tpl in TEMPLATES { + pictures.0.entry(tpl.id).or_insert_with(|| picture(&ctx, tpl.id)); + } + let count = TEMPLATES.len() + 1; + let rows = count.div_ceil(COLUMNS); + let size = vec2(COLUMNS as f32 * (CARD.x + GAP) - GAP + 2.0 * PAD, TITLE_H + rows as f32 * (CARD.y + GAP) - GAP + 2.0 * PAD); + let canvas = app.canvas.rect; + let x = (anchor.max.x - size.x).min(canvas.max.x - size.x - 8.0).max(canvas.min.x + 8.0); + let mut chosen = None; + egui::Area::new(area_id()).fixed_pos(pos2(x, bottom - size.y)).order(egui::Order::Foreground).show(&ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + a11y::container_bounds(ui, rect); + let p = ui.painter(); + p.rect_filled(rect, CornerRadius::same(13), t.menu); + p.text(pos2(rect.min.x + PAD + 2.0, rect.min.y + PAD + 8.0), Align2::LEFT_CENTER, tr("Templates"), crate::theme::semibold(12.0), t.menu_text); + for i in 0..count { + let (col, row) = (i % COLUMNS, i / COLUMNS); + let at = pos2(rect.min.x + PAD + col as f32 * (CARD.x + GAP), rect.min.y + PAD + TITLE_H + row as f32 * (CARD.y + GAP)); + let r = Rect::from_min_size(at, CARD); + let tpl = TEMPLATES.get(i); + let label = tpl.map_or(tr("Table"), |tpl| tr(tpl.name)); + let resp = ui.interact(r, ui.scope_id().with(("board_template", i)), Sense::click()); + a11y::menu_item(&resp, label, "", None); + a11y::own_focus(&resp); + let p = ui.painter(); + if resp.hovered() || resp.has_focus() { + p.rect_filled(r, CornerRadius::same(8), t.menu_hover); + } + let pic = Rect::from_min_size(r.min + vec2(4.0, 4.0), vec2(CARD.x - 8.0, PICTURE_H)); + p.rect_filled(pic, CornerRadius::same(6), crate::theme::c32(figcraft_doc::BOARD_PAGE)); + p.rect_stroke(pic, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside); + match tpl.and_then(|tpl| pictures.0.get(tpl.id)).and_then(Option::as_ref) { + Some(tex) => { + let s = tex.size_vec2() / ctx.pixels_per_point(); + let k = ((pic.width() - 12.0) / s.x).min((pic.height() - 12.0) / s.y).min(1.0); + p.image( + tex.id(), + Rect::from_center_size(pic.center(), s * k), + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + // The table (and a template whose picture couldn't be drawn): its mark. + None => icons::paint( + p, + Rect::from_center_size(pic.center(), vec2(32.0, 32.0)), + if tpl.is_some() { Icon::Templates } else { Icon::Table }, + egui::Color32::from_gray(90), + ), + } + p.text(pos2(r.min.x + 6.0, pic.max.y + 13.0), Align2::LEFT_CENTER, label, regular(12.0), t.menu_text); + if resp.clicked() { + chosen = Some(i); + } + } + }); + ctx.data_mut(|d| d.insert_temp(area_id(), pictures)); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.tool_flyout = None; + } + if let Some(i) = chosen { + let (cmd, params) = match TEMPLATES.get(i) { + Some(tpl) => ("board.template", json!({"name": tpl.id})), + None => ("board.table", json!({})), + }; + app.run(cmd, params); + app.ui.tool_flyout = None; + } +} + +/// The table's part of the bar over the selection from `x`: add a row or a column at the end, +/// remove the last row or column. Where the next part starts. +pub(crate) fn table_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { + let cells = [ + (Icon::AddRow, tr("Add row"), "addRow"), + (Icon::RemoveRow, tr("Remove row"), "removeRow"), + (Icon::AddColumn, tr("Add column"), "addColumn"), + (Icon::RemoveColumn, tr("Remove column"), "removeColumn"), + ]; + for (icon, tip, action) in cells { + let r = Rect::from_center_size(pos2(x + 14.0, cy), vec2(28.0, 28.0)); + if tool_cell(ui, t, r, ("bb_table", action), icon, false, tip).clicked() { + app.run("board.tableEdit", json!({"action": action})); + } + x += 30.0; + } + crate::board_bar::divider(ui, t, x, cy) +} + +/// The table part's width (with the gap after it). +pub(crate) const TABLE_PART_W: f32 = 4.0 * 30.0 + 8.0; diff --git a/crates/ui-egui/src/board_picker_tests.rs b/crates/ui-egui/src/board_picker_tests.rs new file mode 100644 index 00000000..c5860525 --- /dev/null +++ b/crates/ui-egui/src/board_picker_tests.rs @@ -0,0 +1,83 @@ +//! Boards in the UI: the toolbar's templates picker (pictures, a template or a table placed with +//! a click) and the table's part of the bar over the selection. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2}; +use figcraft_doc::{BoardItem, NodeId}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; + +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 board() -> Ui { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + u +} + +/// The templates button: left of the Design panel's button at the board toolbar's right end. +fn templates_button(u: &Ui) -> Pos2 { + let p = crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app)); + pos2(p.max.x - 8.0 - 32.0 - 4.0 - 16.0, p.center().y) +} + +fn count(u: &Ui, f: impl Fn(&BoardItem) -> bool) -> usize { + u.app.session.doc().unwrap().nodes().filter(|n| n.board.as_ref().is_some_and(&f)).count() +} + +#[test] +fn the_templates_picker_places_a_template_or_a_table() { + let mut u = board(); + let at = templates_button(&u); + click(&mut u, at); + assert_eq!(u.app.ui.tool_flyout, Some("board_templates")); + let shapes = flat(&u.frame_output(vec![]).shapes); + for name in ["Retrospective", "Brainstorm", "Weekly planner", "Flow chart", "Kanban board", "Table"] { + assert!(has_text(&shapes, name), "{name} in the picker"); + } + let pictures = shapes.iter().filter(|s| matches!(s, egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default())).count(); + assert!(pictures >= 5, "each template has its picture ({pictures})"); + click(&mut u, text_center(&shapes, "Flow chart")); + assert_eq!(u.app.ui.tool_flyout, None, "a pick closes the picker"); + assert_eq!(count(&u, |b| matches!(b, BoardItem::Connector(_))), 6); + assert!(!u.app.session.selection().is_empty(), "what it placed is selected"); + // Again, for a table. + click(&mut u, at); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Table")); + assert_eq!(count(&u, |b| *b == BoardItem::Table), 1); + // Escape closes it. + click(&mut u, at); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert_eq!(u.app.ui.tool_flyout, None); +} + +#[test] +fn the_selection_bar_adds_and_removes_a_tables_rows_and_columns() { + let mut u = board(); + let t = NodeId(u.app.session.execute("board.table", &json!({"x": 0, "y": 0, "rows": 2, "columns": 2})).unwrap()["id"].as_u64().unwrap()); + u.frame(vec![]); + u.frame(vec![]); + let size = |u: &Ui| { + let doc = u.app.session.doc().unwrap(); + let n = doc.node(t).unwrap(); + let rows: Vec<_> = n.children.iter().filter(|c| !doc.node(**c).unwrap().child_layout.absolute).collect(); + (rows.len(), doc.node(*rows[0]).unwrap().children.len()) + }; + // Add row, remove row, add column, remove column: 30 apart, 8 in. + for (i, want) in [(0, (3, 2)), (1, (2, 2)), (2, (2, 3)), (3, (2, 2))] { + // The bar follows the table's middle as it widens. + let r = u.ctx.memory(|m| m.area_rect(egui::Id::unique("board_bar"))).expect("the bar shows for a table"); + click(&mut u, pos2(r.min.x + 8.0 + i as f32 * 30.0 + 14.0, r.center().y)); + assert_eq!(size(&u), want, "cell {i}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bf893171..d81571c1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -271,8 +271,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } crate::dev_mode::draw(app, &painter); crate::handles::draw(app, &painter); + 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_facilitate::show(app, &ctx, rect); + crate::widget_bar::show(app, &ctx); crate::selection_spacing::draw(app, &painter); crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 43be97f4..5b4da85e 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -133,6 +133,7 @@ fn dynamic_labels() -> Vec<&'static str> { // Board shapes and stamps (the board tools' flyouts and the contextual bar). v.extend(figcraft_doc::BoardShape::ALL.iter().map(|b| b.label())); v.extend(figcraft_doc::Stamp::ALL.iter().map(|b| b.label())); + v.extend(figcraft_engine::board_templates::TEMPLATES.iter().map(|t| t.name)); v.extend(Group::ALL.iter().map(|g| g.label())); v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); v.extend(DATA_LABELS); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index eab5914e..c8ee27e3 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -203,6 +203,14 @@ icons! { PenNib, /// A board file (and the sticky note tool): a note with its bottom corner folded up. Board, + /// Board templates: a board laid out in blocks, a note and lines of text. A table: a grid. + /// The table bar's rows and columns: a strip with a plus or a minus beyond its end. + Templates, + Table, + AddRow, + RemoveRow, + AddColumn, + RemoveColumn, /// Board tools: a chisel-tipped marker; a broad highlighter over its band; a connector /// between two boxes; shapes (a square behind a circle); a stamp (a badge with a check). Marker, @@ -212,6 +220,11 @@ icons! { CurvedConnector, Shapes, Stamp, + /// Running a board session: a stopwatch (the timer), two bars (pause) and a ballot going + /// into a box (voting). + Timer, + Pause, + Vote, /// A measurement: a dimension line with arrowheads between two end ticks. Measure, } @@ -1167,6 +1180,36 @@ fn draw(p: &P, icon: Icon) { p.line(5.0, 6.5, 11.0, 6.5); p.line(5.0, 9.5, 8.0, 9.5); } + Templates => { + p.rect(1.5, 2.5, 14.5, 13.5, 1.5); + p.fill_rect(3.5, 4.5, 7.5, 8.5, 0.5); + p.line(9.5, 5.5, 12.5, 5.5); + p.line(9.5, 7.5, 12.5, 7.5); + p.line(3.5, 11.0, 12.5, 11.0); + } + Table => { + p.rect(1.5, 2.5, 14.5, 13.5, 1.0); + p.line(1.5, 6.5, 14.5, 6.5); + p.line(1.5, 10.0, 14.5, 10.0); + p.line(6.0, 2.5, 6.0, 13.5); + p.line(10.0, 2.5, 10.0, 13.5); + } + AddRow | RemoveRow => { + p.rect(1.5, 2.5, 14.5, 8.5, 1.0); + p.line(8.0, 2.5, 8.0, 8.5); + p.line(5.5, 12.5, 10.5, 12.5); + if icon == AddRow { + p.line(8.0, 10.0, 8.0, 15.0); + } + } + AddColumn | RemoveColumn => { + p.rect(2.5, 1.5, 8.5, 14.5, 1.0); + p.line(2.5, 8.0, 8.5, 8.0); + p.line(10.0, 8.0, 15.0, 8.0); + if icon == AddColumn { + p.line(12.5, 5.5, 12.5, 10.5); + } + } Marker => { p.poly(&[(3.0, 13.0), (3.0, 10.5), (10.0, 3.5), (12.5, 6.0), (5.5, 13.0)], true); p.line(8.5, 5.0, 11.0, 7.5); @@ -1197,6 +1240,22 @@ fn draw(p: &P, icon: Icon) { p.circle(8.0, 8.0, 6.0); p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); } + Timer => { + p.circle(8.0, 9.0, 5.5); + p.line(8.0, 9.0, 8.0, 6.0); + p.line(6.0, 1.5, 10.0, 1.5); + p.line(8.0, 1.5, 8.0, 3.5); + p.line(12.0, 4.0, 13.5, 2.5); + } + Pause => { + p.fill_rect(4.0, 3.0, 6.5, 13.0, 0.5); + p.fill_rect(9.5, 3.0, 12.0, 13.0, 0.5); + } + Vote => { + p.rect(2.5, 8.5, 13.5, 13.5, 1.0); + p.poly(&[(5.5, 8.5), (5.5, 2.5), (10.5, 2.5), (10.5, 8.5)], false); + p.poly(&[(6.6, 5.6), (7.7, 6.7), (9.4, 4.6)], false); + } Measure => { p.line(2.5, 4.0, 2.5, 12.0); p.line(13.5, 4.0, 13.5, 12.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e1fc5fa..c11bf985 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,8 @@ pub mod a11y; pub mod assets_panel; pub mod bidi; pub mod board_bar; +pub mod board_facilitate; +mod board_picker; pub mod branch_ui; pub mod canvas; pub mod cjk; @@ -68,9 +70,12 @@ pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; pub mod versions; +pub mod widget_bar; pub mod widgets; pub mod windows; +#[cfg(test)] +mod board_picker_tests; #[cfg(test)] mod board_ui_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d94ecc14..a5795a8c 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -134,7 +134,7 @@ pub(crate) fn bar_width(app: &FigApp) -> f32 { fn main_width(groups: &[Group], board: bool) -> f32 { let (cell, gap) = (TOOL_CELL, 4.0); let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * (FLYOUT_GAP + FLYOUT_W); - let end = if board { cell + 16.0 } else { 4.0 * 34.0 + 24.0 }; + let end = if board { 2.0 * cell + gap + 16.0 } else { 4.0 * 34.0 + 24.0 }; groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end } @@ -157,6 +157,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: x += 8.0; if board { // Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for. + x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t); let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let tip = crate::shortcuts::tip(tr("Design panel"), "view.design"); if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() { diff --git a/crates/ui-egui/src/widget_bar.rs b/crates/ui-egui/src/widget_bar.rs new file mode 100644 index 00000000..c32002d6 --- /dev/null +++ b/crates/ui-egui/src/widget_bar.rs @@ -0,0 +1,113 @@ +//! The small bar floating over a selected plugin widget that has a property menu: its actions and +//! toggles as buttons, its colour choices as swatches. Each calls `widget.menu`; the labels are the +//! plugin's own. + +use egui::{CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::widgets::{MenuItem, MenuKind, menu_of}; +use serde_json::json; + +use crate::FigApp; +use crate::a11y; +use crate::board_bar::{Mark, divider, paint_choice, place}; +use crate::theme::{Tokens, semibold}; + +const HEIGHT: f32 = 40.0; +const CELL: f32 = 28.0; +const SWATCH: f32 = 20.0; +const GAP: f32 = 4.0; +/// A text button's padding on each side. +const PAD: f32 = 8.0; + +/// What one item takes across the bar, its trailing gap included (text buttons by their label). +fn width(ctx: &egui::Context, m: &MenuItem) -> f32 { + match m.kind { + MenuKind::Color => m.options.len() as f32 * (SWATCH + GAP), + _ => ctx.fonts_mut(|f| f.layout_no_wrap(m.label.clone(), semibold(11.0), egui::Color32::WHITE).size().x) + PAD * 2.0 + GAP, + } +} + +/// The bar above the selected widget, while it is the only layer selected, nothing is being dragged +/// or edited and the file can be edited. +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + if app.session.tool.busy() || app.session.active_read_only().is_some() { + return; + } + let Ok(ds) = app.session.ds() else { return }; + let ([w], None) = (ds.selection.as_slice(), &ds.text_edit) else { return }; + let w = *w; + let menu = menu_of(&ds.doc, w); + let Some(b) = ds.doc.world_bounds(w).filter(|_| !menu.is_empty()) else { return }; + let sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + // Scrolled out of view: no bar (it would float over the rest of the window). + if !sel.intersects(app.canvas.rect) { + return; + } + let widths: Vec = menu.iter().map(|m| width(ctx, m)).collect(); + // The items, a divider (8) between each two, and the padding. + let size = vec2(8.0 + widths.iter().sum::() + 8.0 * (menu.len() - 1) as f32 + 4.0, HEIGHT); + let at = place(sel, size, app.canvas.rect); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::unique("widget_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::click()); + a11y::container_bounds(ui, rect); + let p = ui.painter(); + p.rect_filled(rect.expand(2.0).translate(vec2(0.0, 1.0)), CornerRadius::same(12), egui::Color32::from_black_alpha(18)); + p.rect_filled(rect, CornerRadius::same(10), t.panel); + p.rect_stroke(rect, CornerRadius::same(10), Stroke::new(1.0, t.border), StrokeKind::Inside); + let cy = rect.center().y; + let mut x = rect.min.x + 8.0; + for (i, (m, wd)) in menu.iter().zip(&widths).enumerate() { + if i > 0 { + x = divider(ui, &t, x, cy); + } + if let Some(value) = item(ui, &t, m, x, cy, *wd) { + app.run("widget.menu", json!({"id": w, "item": m.item, "value": value})); + } + x += wd; + } + }); +} + +/// Draw item `m` from `x`, `wd` wide; the value to send when it's used. +fn item(ui: &mut Ui, t: &Tokens, m: &MenuItem, x: f32, cy: f32, wd: f32) -> Option { + if m.kind == MenuKind::Color { + let mut picked = None; + for (i, hex) in m.options.iter().enumerate() { + let r = Rect::from_center_size(pos2(x + i as f32 * (SWATCH + GAP) + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); + let tip = format!("{} {hex}", m.label); + let resp = ui.interact(r, ui.scope_id().with(("wb_color", &m.item, i)), Sense::click()).on_hover_text(&tip); + let on = m.value.as_ref() == Some(hex); + a11y::toggle(&resp, &tip, on); + if on { + ui.painter().rect_stroke(r.expand(2.5), CornerRadius::same(6), Stroke::new(1.5, t.text), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); + } + if let Some(c) = figcraft_color::Color::from_hex(hex) { + paint_choice(ui.painter(), r, &Mark::Color(c), t.icon); + } + if resp.clicked() && !on { + picked = Some(json!(hex)); + } + } + return picked; + } + let r = Rect::from_min_size(pos2(x, cy - CELL / 2.0), vec2(wd - GAP, CELL)); + let resp = ui.interact(r, ui.scope_id().with(("wb_item", &m.item)), Sense::click()); + let on = m.kind == MenuKind::Toggle && m.checked; + if m.kind == MenuKind::Toggle { + a11y::toggle(&resp, &m.label, on); + } else { + a11y::button(&resp, &m.label); + } + let bg = if on { + t.accent + } else if resp.hovered() { + t.hover + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(r, CornerRadius::same(5), bg); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, &m.label, semibold(11.0), if on { t.accent_on } else { t.text }); + resp.clicked().then(|| if m.kind == MenuKind::Toggle { json!(!m.checked) } else { serde_json::Value::Null }) +} diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 0f5884dd..af42d363 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -370,7 +370,9 @@ fn the_unsaved_changes_question_offers_save_dont_save_and_cancel() { let (ctx, mut app, _) = asking(None, vec![]); ctx.enable_accesskit(); frame(&ctx, &mut app, false); - let out = ctx.run_ui(RawInput::default(), |ui| app.ui(ui)); + let mut out = ctx.run_ui(RawInput::default(), |ui| app.ui(ui)); + // A canvas picture that lands in this frame uploads a texture nobody applies here. + out.textures_delta.clear(); let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; let buttons: Vec = nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Button).filter_map(|(_, n)| n.label().map(str::to_string)).collect(); diff --git a/docs/boards-parity.md b/docs/boards-parity.md index ca358076..fcf44627 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,13 +1,14 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (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-24 · **Change:** minor (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 ~37%, ready ~21% (estimated, erring low).** A board is a FigCraft file of its own kind +**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 follow what they join (straight, elbow or curved, their ends dragged onto other objects), marker, -highlighter and eraser, stamps, text and sections, edited together in the existing live sessions. -The facilitation tools (timer, voting, widgets), templates, tables, mind maps and the rest of the -tail are missing. Remaining: ~28–56 h. Tracked as area 22 of +highlighter and eraser, stamps, text and sections, five built-in templates and tables, edited +together in the existing live sessions, with a shared countdown and voting sessions to run a +workshop. Music, board widgets, mind maps and the rest of the tail are missing. Remaining: +~20–44 h. Tracked as area 22 of [`target-app-parity.md`](target-app-parity.md) (not in its weighted sum: the target there is the design product). The work item is gap 24 in [`gaps.md`](gaps.md). @@ -44,11 +45,13 @@ all end with the same geometry. | Stamps sticking to the object they land on, the stamp wheel, emotes, stickers | missing | | | Text and sections | done | the existing Text tool and sections | | Live sessions: cursors, selections, cursor chat, spotlight | done | the existing sessions; board objects, connectors following moved objects, colours and stamps sync both ways (checked with two app instances) | -| Timer and music | missing | | -| Voting sessions | missing | | +| Timer | done | a pill at the top right of the board: presets (1, 3, 5, 10, 15 min) or any whole minutes, pause, resume, a minute more, stop; red at 0:00 until stopped; no sound. `board.timer {action, minutes?}`, `board.timerState`. Synced as its end time (or the time left while paused), so every client counts down on its own clock: nothing ticks over the network. Not an undo step | +| Timer music, timer sounds | missing | | +| Voting sessions | done | one at a time: votes per person (1–99), on every object, on sticky notes or on the selection; a click on an object places a vote (several on one object allowed), a click on your badge or an Alt-click takes it back; your own votes show as badges while it runs, everyone's once it ends, with a ranked list (ties share a rank) that selects an object from its row; Clear results. `board.vote {action: start\|cast\|retract\|end\|clear}`, `board.voteResults`. Ballots sync per voter (keyed by the live-session client id, or your name offline), so people voting at once never overwrite each other. Not undo steps | +| Voting: only the facilitator ends or clears it, who voted for what | missing | anyone who can edit the board can end or clear a vote; results are counts only | | Board widgets | missing | design-file widgets exist (`crates/engine/src/widgets.rs`), not wired to boards | -| Templates (retrospective, brainstorm, flow chart…) | missing | | -| Tables | missing | | +| Templates (retrospective, brainstorm, flow chart…) | partial | five built-in templates drawn up for FigCraft (retrospective, brainstorm, weekly planner, flow chart, kanban board), placed from the toolbar's templates picker (each with a picture of itself) or `board.template {name, x?, y?}` as ordinary sections, stickies, shapes, connectors and text in one undo step; `board.templates` lists them. No community templates, no saving your own | +| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells, no CSV paste | | Mind maps | missing | | | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | | @@ -61,4 +64,6 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | +| 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h | +| 2026-10-24 | minor | Templates (retrospective, brainstorm, weekly planner, flow chart, kanban board) from a toolbar picker or `board.template`, and tables (`board.table`, `board.tableEdit`): breadth ~35% → ~40%, ready ~20% → ~24%, remaining 30–60 h → 26–54 h | | 2026-10-24 | major | Created with board files (M12): sticky notes, shapes with text, connectors, ink and eraser, stamps, the board toolbar and contextual bar, live-session sync | diff --git a/docs/cli.md b/docs/cli.md index 1e1e1967..68e37823 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,6 +25,12 @@ On Linux and other Unix systems except macOS, user fonts are read from `$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset, empty or relative. The legacy `$HOME/.fonts` directory is also scanned. +On macOS the font folders (`/System/Library/Fonts`, `/Library/Fonts`, `~/Library/Fonts` and the +system's downloadable fonts) are read, and so is every other font file Core Text lists as +available: fonts a font manager such as RightFont, FontBase or Typeface activates from its own +library, without copying them into a font folder. Fonts activated while the app runs are found by +`text.rescanFonts`, like newly installed ones. + On macOS, fonts activated through Adobe Fonts are read where Creative Cloud keeps them (`~/Library/Application Support/Adobe/CoreSync/plugins/livetype`), so they're available while they're active there. Files without an extension are read when they start like a font file. diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 38ef96ec..641a7040 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -99,6 +99,15 @@ fit FigCraft's architecture: collide with what a connected client makes, because a client's numbers continue above every id of its space already in the document. Removed entries are part of the saved session state (`SessionState::removed`). +- **Branches** of a file in a session stay with the person who made them; nothing about them goes + over the wire until a merge, which is an ordinary batch from that person (no message or format + change). So that the server takes the layers a branch made, a branch made in the session makes + its ids in its author's own client space, 2^24 above every id made there on the main file and the + author's other branches. Layers of the branch's base that main deleted during the session come + back with their ids (the server accepts re-creating deleted ids). Merging a branch whose new + layers, styles, variables or comments lie in another id space (one made offline or in an earlier + session) is refused by the app before anything is sent, since the server would refuse it (see + `crates/engine/src/branches.rs`). - Objects inside an entry are not separate objects: modes are part of their collection, a variable's values per mode part of the variable and replies part of their comment thread. Two people adding a mode to the same collection, editing different modes of one variable, or replying @@ -142,7 +151,11 @@ As built: colour profile, image presets, annotation categories and library data (whole values); styles, comment threads, variable collections and variables (one entry per id plus the order of ids; the whole list when ids aren't unique); dev-mode snapshots (per layer); images (per content - hash, the encoded bytes as base64). The page order is the pages' positions. + hash, the encoded bytes as base64); a board's timer and vote (whole values, `DocProp::Timer` and + `DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting + at once write different entries). The timer travels as its end time (or, paused, the time left), + never as ticks: each client counts down on its own clock. The page order is the pages' + positions. - **Next**: this file's published library (it embeds a snapshot document; it moves with an asset channel, together with images, which then stop travelling inside batches); the version history (kept beside the document, not in it) on the standalone server; finer entries for modes, diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index ae503633..c9af478f 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | -| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | +| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | | Notifications, activity | yes | no | with sharing | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index d9203349..8fce3103 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -19,7 +19,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | | 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | -| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | +| 6 | Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | | 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | @@ -30,14 +30,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | | 16 | Dev Mode: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) | -| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | +| 17 | Branching: text runs merged whole, branches only their author sees in a live session (branches in live sessions, export settings and guides item by item, and history entries for creation and updates landed 2026-10-24) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | | 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | | 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | | 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | | 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | -| 24 | Boards: the whiteboard tail (timer, voting, widgets, templates, tables, mind maps…) | Feature | 28–56 | [boards-parity](boards-parity.md) | +| 24 | Boards: the whiteboard tail (widgets, mind maps, waypoints…) | Feature | 20–44 | [boards-parity](boards-parity.md) | ## Entries @@ -207,7 +207,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe [`localization-parity.md`](localization-parity.md); how to add one in [`translating.md`](translating.md). -### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run +### 6. Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run - **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in the browser) acting through the command registry, one undo step per run, manifest permissions, Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples @@ -224,15 +224,20 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`. + Widget fields and property menus (plugin API 4, 2026-10-24): a widget's text layer can be an + editable field, edited on the canvas with a double-click like any text or with `widget.setText` + (the value, cleaned and capped, goes into the widget's state and the plugin redraws; one undo + step), and a widget can carry a property menu of actions, toggles and colour choices, shown in a + small bar while it's selected and driven with `widget.menu`; the Counter example uses both. - **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings - and install counts, background update checks, the directory in the browser build; widgets take only clicks (no - text input, property menus, hover or timers) and draw only frames, text, shapes and images; + and install counts, background update checks, the directory in the browser build; widgets have no hover or + timers, can't open a panel and draw only frames, text, shapes and images; no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and don't run. - **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better than Figma does. -- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins +- **Estimate:** 6–22 h for the missing pieces of FigCraft's own API. Running Figma's plugins needs a JS engine and an API shim: **owner decision needed**. ### 7. CPU canvas where Figma uses the GPU @@ -428,9 +433,26 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe different items changed on each side, or items added at the end on one side, merge without a conflict; only the same item changed both ways, an item removed on one side, or different items added on both sides conflict (as the whole list). -- **Missing:** branches of a file in a live session are refused; other lists (text runs, export - settings, guides…) still conflict as a whole; the branch history has no entries of its own for - its creation or updates from main (only saved versions, autosaves and the merge on main). +- **Landed (2026-10-18, #798):** layout grids, export settings, guides, prototype interactions, + accessibility annotations and Dev Mode resources merge item by item like fills (code links merge + whole: one per language). +- **Landed (2026-10-24):** branches of a file in a live session: creating, editing, reviewing, + updating from main and merging work while the file is in a session. A branch stays with the + person who made it; merging it is one edit of the session document that everyone gets, and one + step of its author's own undo history. A branch made in the session makes its ids in its author's + own client space, 2^24 above every id made there on the main file, so the session server takes + the layers it adds; others' edits on main while it is open are main's changes against the + branch's own base, and conflicts with them resolve as offline (a layer a peer deleted comes back + with its id when the branch's edits win). A view link still refuses merging, and merging a branch + made outside the session that adds layers, styles, variables or comments (ids in another space) + is refused with a reason instead of being sent for the server to refuse. A branch's history gets + entries for its creation and each update from main. Engine tests through a session server with + several people, a fuzz test with random real commands (export settings and guides included, + offline and live) and a junk-parameter sweep of the branch commands in a session. +- **Missing:** text runs still conflict as a whole (runs are character ranges into the text, so + lining them up item by item would need a text diff, not positions); branches in a live session + are their author's only (others can't see or open them: that needs the server to keep them); + a branch made offline that adds layers merges only outside a session. - **Estimate:** 1–2 h. ### 18. Display P3 on screen (several windows landed) @@ -475,17 +497,21 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 24. Boards: the whiteboard tail - **Landed (2026-10-24, M12):** board files (`file.newBoard`) with sticky notes, shapes with text, connectors that re-route when what they join moves, marker, highlighter and eraser, stamps, text - and sections; a board toolbar and a contextual bar; live sessions sync it all. + and sections; a board toolbar and a contextual bar; live sessions sync it all. Then (also M12) a + timer everyone on the board sees and voting sessions with per-person caps, hidden votes and + ranked results (`board.timer`, `board.vote`), and five built-in templates (retrospective, + brainstorm, weekly planner, flow chart, kanban board) from a toolbar picker or `board.template`, + and tables (`board.table`, `board.tableEdit`, up to 50 × 20) made of auto layout frames. - **Landed (2026-10-24, M12):** a dot grid on board pages, under the layers; a selected connector's ends drag onto another object or empty canvas (`board.reattach`); curved connectors; elbows that go around the objects they join. -- **Missing:** timer and music, voting sessions, board widgets, templates, tables, mind maps, washi - tape, code blocks, audio and huddles, quick-create handles, connector waypoints placed by hand, - stamps sticking to the objects they land on, `.jam` files +- **Missing:** timer music, board widgets, saving your own templates, table column resizing and + cell colours, mind maps, washi tape, code blocks, audio and huddles, quick-create handles, + connector waypoints placed by hand, stamps sticking to the objects they land on, `.jam` files ([`boards-parity.md`](boards-parity.md)). -- **User impact:** boards cover a brainstorm or a flow sketch; facilitated workshops need the - timer, voting and templates. -- **Estimate:** 28–56 h. +- **User impact:** boards cover a brainstorm, a retrospective, a flow sketch and a timed, voted + workshop round. +- **Estimate:** 20–44 h. ## Revision history @@ -495,6 +521,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged | | 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged | | 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | +| 2026-10-24 | minor | Gap 24: a shared board timer and voting sessions landed (M12); 30–60 h → 26–54 h, ranking unchanged | +| 2026-10-24 | minor | Gap 24: board templates (retrospective, brainstorm, weekly planner, flow chart, kanban board; `board.template`, a toolbar picker) and tables (`board.table`, `board.tableEdit`); 30–60 h → 26–54 h, rank unchanged | +| 2026-10-24 | minor | Gap 6: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`, a bar over the selected widget), plugin API 4; 8–26 h → 6–22 h; ranking unchanged | +| 2026-10-24 | minor | Gap 17: branches of a file in a live session (create, edit, review, update from main and merge while the file is in a session; the branch stays with its author and makes ids in their own client space far above the main file's; merging is one synced edit and one step of the author's undo history; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers is refused with a reason); a branch's history gets entries for its creation and updates from main; the list-merge wording brought up to date with #798 (export settings and guides already merged item by item); text runs still merge whole. Ranking and hours unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/images/shot-8-board.png b/docs/images/shot-8-board.png new file mode 100644 index 00000000..ef0b9d49 Binary files /dev/null and b/docs/images/shot-8-board.png differ diff --git a/docs/images/shot-9-devmode-measure.png b/docs/images/shot-9-devmode-measure.png new file mode 100644 index 00000000..8dde99d7 Binary files /dev/null and b/docs/images/shot-9-devmode-measure.png differ diff --git a/docs/mcp.md b/docs/mcp.md index 1a1f3a75..b930b881 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -414,6 +414,17 @@ one undo step and returns the ids it made: - `board.setColor {ids?, color}`: stickies and shapes take it as their fill, connectors and ink as their line. `board.setStyle {ids?, shape?, fontSize?, style?: straight|elbow|curved, startCap?, endCap?}`: a shape's outline and text size, a connector's routing and arrowheads. +- `board.templates` → `[{id, name, description, width, height}]` lists the built-in templates: + `retrospective brainstorm weeklyPlanner flowChart kanban`. `board.template {name, x?, y?}` → + `{name, ids}` places one (its top-left at `x`, `y`; centred in the view without them) as ordinary + sections, stickies, shapes, connectors and text, selected, in one undo step. +- `board.table {rows?, columns?, cells?, x?, y?}` → `{id, rows, columns, cells}`: a table (default + 3 × 3, at most 50 × 20; `cells` is `[["Name", "Role"], …]` row by row and sets the size when + `rows`/`columns` are left out). It is an auto layout frame of row frames holding cell frames, each + with one text layer; `cells` in the answer holds those text layers' ids row by row, so + `node.set {id, props: {characters}}` changes a cell. A cell's row grows with its text. `board.tableEdit {id?, action: addRow|addColumn|removeRow|removeColumn, + index?}` → the same answer: `index` is where the new row or column goes, or which one goes + (default the last); `id` may be the table or any layer in it. - Tools for `pointer`: `tool.sticky {color?}`, `tool.shape {shape?}`, `tool.connector {style?: straight|elbow|curved}` (drag from one object to another), `tool.marker {color?}`, `tool.highlighter {color?}`, `tool.eraser` (drag over strokes), `tool.stamp {stamp?}`; Text and Section are the usual tools. @@ -430,6 +441,40 @@ one undo step and returns the ids it made: `{"id": 4, "text": 5}` in a new board without an author name; a sticky with its author's name takes one more id). In a `batch`, step references (`{"from": {"$step": 1}, "to": {"$step": 2}}`, the `id`s steps 1 and 2 returned) avoid counting. +Running a session on a board: a countdown and votes. Both are part of the board, so everyone in a +live session sees the same, and neither is an undo step (undo leaves them as they are). On a +design file they refuse. + +- `board.timer {action: start|pause|resume|add|stop, minutes?}` → `{state, left, text, ends}`: one + countdown per board. `start` takes `minutes` (default 5) and `add` adds them (default 1), from + one second up to 1440 minutes in all; `pause` keeps the time left, `resume` counts it down again. + The timer keeps its end time (milliseconds since 1970) or, paused, the time left, and each + client counts down on its own clock. `board.timerState {}` reads it: `state` is `off`, + `running`, `paused` or `done` (it ran out: 0:00 until someone stops it), `left` milliseconds, + `text` as `m:ss` (or `h:mm:ss`). +- `board.vote {action: start|cast|retract|end|clear, …}` → the vote as `board.voteResults` gives + it. One vote at a time: `start {votes? (per person, 1–99, default 3), on?: all|stickies|selection + (default all), ids? (with selection; default the selected layers)}` (a vote that ended may be + replaced; one still running is refused), `cast {id}` puts one of your votes on an object (several + on the same object are fine; once yours are used up it refuses), `retract {id}` takes one back, + `end` shows everyone's votes, `clear` removes the vote and its results. `voter` (any action) + votes as someone else, for scripts and tests; by default you are your live-session client, or + your name (`pref.userName`) when the file isn't shared. In the app a click on an object votes + while a vote runs, and a click on your badge (or an Alt-click) takes the vote back. +- `board.voteResults {voter?}` → `{state: off|running|ended, votesPerPerson, on, by, left, mine: + [{id, votes}], voters, results?}`: while it runs you see your own votes only (`mine`, `left`) and + how many have voted; once it ends `results` lists `[{id, name, votes, rank}]`, most votes first + (ties share a rank), with `name` the sticky's text or the layer's name. Votes on deleted objects + don't count. + +```json +{"command": "board.timer", "params": {"action": "start", "minutes": 10}} +{"command": "board.vote", "params": {"action": "start", "votes": 2, "on": "stickies"}} +{"command": "board.vote", "params": {"action": "cast", "id": 2}} +{"command": "board.vote", "params": {"action": "end"}} +{"command": "board.voteResults", "params": {}} +``` + ### Sections, handoff and arrow keys `run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or @@ -1123,7 +1168,8 @@ branches). Driven with `run_command`: - `branch.create {name?}` copies the file into a new branch tab and returns `{id, name, index}`. The branch remembers the file as it is now (its base) and makes new ids in its own id space, so - layers, styles and comments made on both sides never clash. + layers, styles and comments made on both sides never clash. Its history starts with a `Branch + created` version. - `branch.list` lists the branches of the file shown (or of the file a branch tab belongs to): `[{id, name, time, state: active|merged|archived, index?}]` (`index`: its open tab). - `branch.open {id}` opens a branch in a tab (or switches to it). Edit it like any document. @@ -1145,15 +1191,26 @@ branches). Driven with `run_command`: conflict key to `"branch"` or `"main"`; the merge is refused while a conflict has none, so neither side's work is dropped without a choice. - `branch.updateFromMain {id?, resolutions?}` brings the main file's changes since the base into the - branch the same way (the new base is the main file as it is now). It is not undoable: the branch - tab's undo history starts over. + branch the same way (the new base is the main file as it is now) and adds an `Updated from main` + version to the branch's history. It is not undoable: the branch tab's undo history starts over. - `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be opened, but not merged). Each branch keeps its own version history: on a branch tab, `file.saveVersion`, `version.autosave` and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions, which are saved in the file with the branch and stay with it when it is merged -or archived. Branches aren't available in a live session. +or archived. + +In a live session branches work the same way and stay with the person who made them (others +don't see them). A branch made in the session makes its ids in your own client space, far above +the ids the main file makes, so the session server takes them. Everyone's edits on main while the +branch is open are main's changes in the review (against the branch's own base), and conflicts +with them are resolved as offline. `branch.merge` is one edit of the session document: everyone +gets it, and it is one step of your own undo history. It is refused on a view link, and for a +branch made outside the session (or in an earlier one) whose new layers, styles, variables or +comments lie in another id space, which the server would refuse; merge those once the file has +left the session. While a session is still starting, branch commands that need your client id +wait for it. ### Collaboration changes @@ -1384,17 +1441,24 @@ Plugin settings are kept per plugin with the preferences (in a headless session, {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"generate"}}}} ``` -Widgets (API 3, layers on the canvas a plugin draws) are driven the same way: `widget.insert +Widgets (API 3 and 4, layers on the canvas a plugin draws) are driven the same way: `widget.insert {plugin, x?, y?}` puts one on the page (centred in the view without a position) and returns `{id, plugin, state, targets, output, logs, calls, fuel}`; `widget.click {id, target}` clicks one of its `targets` as a click on the canvas would (the plugin redraws it; one undo step; returns the same); -`widget.list` lists every widget in the file with its plugin, page, state and targets. A widget's -state is layer data, so it is saved, undone and synced with the file. +`widget.list` lists every widget in the file with its plugin, page, state and targets. With plugin +API 4 a widget can have editable text fields and a property menu (both listed with it, as `fields` +and `menu`): `widget.setText {id, field, value}` sets a field as typing in it would (the value, +at most 1,000 characters, goes into the state under the field's name and the plugin redraws; one +undo step), and `widget.menu {id, item, value?}` uses a menu item (a toggle's `value` is `true` or +`false`, by default the opposite of now; a colour's is one of its options). A widget's state is +layer data, so it is saved, undone and synced with the file. ```json {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.insert","params":{"plugin":"org.figcraft.examples.counter"}}}} {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.click","params":{"id":12,"target":"increment"}}}} {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.list","params":{}}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.setText","params":{"id":12,"field":"label","value":"Votes"}}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.menu","params":{"id":12,"item":"big"}}}} ``` Plugins from a `figcraft-server`'s plugin directory ([`plugins.md`](plugins.md#plugin-directories)): diff --git a/docs/plugins.md b/docs/plugins.md index 73c93cf6..2d4001bc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -18,7 +18,8 @@ permissions its manifest asks for, and nothing more. plugin can also keep settings from one run to the next. - **Widgets (API 3).** A plugin can put a layer of its own on the canvas: it describes the layer (frames with auto layout, text, shapes, images), FigCraft builds it, and a click on one of its - parts calls the plugin back to redraw it from its state, which is kept in the document. + parts calls the plugin back to redraw it from its state, which is kept in the document. Since + API 4 a widget can also have editable text fields and a small property menu. - **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and can narrow the commands it may call. FigCraft enforces both. - **Distribution.** A team's own `figcraft-server` can keep a plugin directory: plugins are @@ -91,6 +92,17 @@ and its state (a small JSON object) in its own layer data, so a saved file, a co a live session carries the widget as it is, and a copy is a widget of its own. A widget whose plugin isn't installed stays as it was drawn; clicking it says the plugin is missing. +**Fields and menus (API 4).** A text layer of a widget can be an editable field: double-click it +with the Move tool and it is edited like any text (all of it selected first). When editing ends +(Escape, or a click elsewhere), the typing is put back and the new value goes to the widget +instead: it is cleaned (line breaks kept, tabs become spaces, other control characters dropped), +refused when longer than 1,000 characters, written into the widget's state under the field's +name and into the field's text, and the plugin is called back to redraw, all as one undo step +(none when the text didn't change). The value lives in the state, so it syncs and saves like the +rest of it. A widget can also carry a property menu: while it is the only layer selected, a +small bar over it shows its actions and toggles as buttons and its colour choices as swatches; +using one calls the plugin back, one undo step. The labels are the plugin's own text. + In a presented prototype (the presenter or the preview pane) clicks on a widget's clickable parts call its plugin back the same way, with the same limits, but on the prototype's own copy of the file: the widget changes in the prototype only, the file and its undo history stay as they are, @@ -115,6 +127,8 @@ From scripts, the CLI and MCP the same commands apply, panels included: {"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.counter", "x": 0, "y": 0}} {"command": "widget.list", "params": {}} {"command": "widget.click", "params": {"id": 12, "target": "increment"}} +{"command": "widget.setText", "params": {"id": 12, "field": "label", "value": "Votes"}} +{"command": "widget.menu", "params": {"id": 12, "item": "color", "value": "#14AE5C"}} ``` `figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over @@ -132,9 +146,11 @@ plugins: import them in the script. | `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. | | `plugins.runLast` | `{}` | Runs the last plugin command again. | | `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. | -| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | +| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, fields, menu, output, logs, calls, fuel}`: `targets` are the names of its clickable parts, `fields` of its editable fields, `menu` its property menu items. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | -| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | +| `widget.setText` | `{id, field, value, fuel?}` | Sets editable field `field` of widget `id` (or of the widget a layer `id` is in) to the text `value`, as editing it on the canvas would: the value (at most 1,000 characters, cleaned) goes into the state under the field's name and into the field's text, and the plugin is called back. One undo step; returns like `widget.insert`. | +| `widget.menu` | `{id, item, value?, fuel?}` | Uses item `item` of widget `id`'s property menu, as its bar would: an action takes no `value`; a toggle takes `true` or `false` (default: the opposite of now); a colour takes one of its options (hex, any case). The plugin is called back. One undo step; returns like `widget.insert`. | +| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets, fields, menu}]}`: every widget in the file. | | `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | @@ -224,7 +240,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. | | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | -| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | +| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files, widget fields and property menus). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | | `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"). A plugin directory's entries don't carry it (their wire format stays as it is), so installing any API 4 plugin from one says it may offer files to save. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | @@ -263,7 +279,9 @@ WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100 16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and -its images 1 MB each, 2 MB together and 4096 pixels on a side. +its images 1 MB each, 2 MB together and 4096 pixels on a side. A property menu has at most 8 +items (labels up to 32 characters), a colour item 1–8 colours; a field's value is at most 1,000 +characters. Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the allocation or copy. This also applies when the configured per-call overhead is zero. A reply @@ -272,9 +290,11 @@ every ABI import, including calls which only query the reply length. ## ABI (version 4) -Version 2 adds panels and settings to version 1, version 3 adds widgets and version 4 adds -`save_file`; a version 1, 2 or 3 -module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values +Version 2 adds panels and settings to version 1, version 3 adds widgets, and version 4 adds +`save_file` and widget fields and property menus (new keys in the widget description and new +widget events); a module written for an older version runs unchanged and gets the input it always +got. A widget description with a field or a menu from a plugin whose manifest says `3` is refused, +and a FigCraft that runs an older API refuses a manifest that asks for `4` when it's imported. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`. The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts, @@ -300,6 +320,15 @@ for a new widget) and what was clicked (`event` is null when it's inserted): {"api": 3, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"target": "increment"}}} ``` +With API 4 the event can also be an edited field (its value is already in `state` under the +field's name) or a property menu item (`value`: null for an action, the new `true`/`false` for a +toggle, the colour picked for a colour item): + +```json +{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2, "label": "Votes"}, "event": {"field": "label", "value": "Votes"}}} +{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"menu": "color", "value": "#14AE5C"}}} +``` + It returns `0` for success; anything else fails the run (and rolls it back), with the text passed to `set_output` as the error message. @@ -370,11 +399,24 @@ ignored in auto layout), `width` and `height`, `fill` and `stroke` (hex colours) `vertical`), `gap`, `padding`, `justify` and `align` (`min`, `center`, `max`: along and across the layout) and `children`; with auto layout it hugs its contents on an axis without a size. A text has `text`, `size`, -`color` and `bold` (with a `width` it wraps). An image has `data`, the image's bytes (PNG, JPEG…) +`color` and `bold` (with a `width` it wraps), and with API 4 may have a `field` (a name like a +target's): the text is then an editable field showing its current value. An image has `data`, the image's bytes (PNG, JPEG…) as base64, usually carried in the module itself; without a size it takes the image's. FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth, image sizes) and refuses it if anything is off. +With API 4, `menu` beside `root` is the widget's property menu (left out: none), for example: + +```json +"menu": [{"item": "reset", "label": "Reset"}, + {"item": "big", "label": "Steps of 10", "type": "toggle", "checked": true}, + {"item": "color", "label": "Colour", "type": "color", "options": ["#1E1E1E", "#0D99FF"], "value": "#0D99FF"}] +``` + +Each item has an `item` name (like a target's, each once), a `label` (default: the name) and a +`type`: `action` (the default), `toggle` (with `checked`) or `color` (with `options`, 1–8 hex +colours, and optionally `value`, one of them: the current one). Every redraw replaces the menu. + ## Writing a plugin in Rust `plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate @@ -390,8 +432,9 @@ image sizes) and refuses it if anything is off. button; the palette picked last is remembered as a setting for next time. **From hex colours…** takes a palette of hex colours in the parameters form. -- **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the - count kept in the widget's state. It only reads the document. +- **Counter** (`counter`, API 4): a widget, a count between − and + buttons under a label; each + click changes the count kept in the widget's state, a double-click on the label edits it, and its + menu resets the count, counts in tens or colours it. It only reads the document. - **Export names** (`export-names`, API 4): saves the names of the selected layers (optionally the layers inside them too) as `layer-names.txt`. It reads the document and offers the file with `save_file`; FigCraft asks where it goes. @@ -455,8 +498,10 @@ rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `cl panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With `"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With -`"api": 4` and `"permissions": {"saveFile": true}`: `save_file(name, mime, &bytes)` offers a file; -FigCraft asks the user where it goes and the plugin never learns a path. Outside +`"api": 4`, `input.widget` also has `field` or `menu` and the `value` that came with it, and the +description may have fields and a `menu`; with `"permissions": {"saveFile": true}` too, +`save_file(name, mime, &bytes)` offers a file; FigCraft asks the user where it goes and the plugin +never learns a path. Outside WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`. Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`, @@ -469,8 +514,8 @@ import what you need from `figcraft`. install counts, and updates are found when you browse, not in the background. The browser build can't use a directory yet. - Plugins written for other apps (JavaScript plugins) don't run. -- Widgets: clicks are their only events (no text input, hover, property menu or timers), they - can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no +- Widgets: clicks, field edits and menu items are their only events (no hover or timers), a field + is plain text (no placeholder, length or single-line setting), they can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no vectors, components or variables). - Panels: no images, sliders or free layout inside a panel. - Data in the document: plugins keep data on layers (pages included) with `node.set {props: diff --git a/docs/roadmap.md b/docs/roadmap.md index 503b514d..c22f88f8 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-24 · **Change:** minor (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-24 · **Change:** minor (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 @@ -59,8 +59,8 @@ blockers. | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 | -| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | -| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions | timer, voting, widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 30–60 | +| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 | +| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 20–44 | | M13 AI features (new, owner decision) | not started | local models | 30–60 | ## Next, after the current focus @@ -76,6 +76,8 @@ blockers. | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | +| 2026-10-24 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h | +| 2026-10-24 | minor | M11: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`), plugin API 4; 8–26 h → 6–22 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Milestone M12 Boards added; AI features move to M13 | | 2026-10-21 | minor | M6: best-effort `.sketch` import (Sketch 48 and newer), see [file-format-parity](file-format-parity.md). Percentages unchanged | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dc3ce78b..5c8dcc01 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; 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, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (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; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; 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, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) | | Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.7% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) | | Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | @@ -113,11 +113,11 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 15 | Dev Mode / inspect | 2 | 98% | 78% | Publishing mappings from the code repository; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode, dev resources links, import lines, compare with versions, an inspect-only canvas and layers panel and measurements kept on the canvas landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–2 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | +| 18 | Plugins & widgets | 4 | 65% | 38% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879), then widget fields and property menus (API 4: a text layer edited on the canvas like any text or with `widget.setText`, its value kept in the widget's state; actions, toggles and colour choices in a bar over the selected widget, `widget.menu`). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets have no hover or timers and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| 22 | Boards (whiteboarding) | 0 | ~37% | ~21% | Board files with a dot grid, sticky notes, shapes with text, connectors that follow what they join (straight, elbow around the objects, or curved; ends dragged to re-attach), marker, highlighter and eraser, stamps, text and sections, in live sessions (M12); missing: timer, voting, board widgets, templates, tables, mind maps, washi tape, audio, quick-create handles, connector waypoints, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 28–56 | +| 22 | Boards (whiteboarding) | 0 | ~47% | ~29% | Board files with a dot grid, sticky notes, shapes with text, connectors that follow what they join (straight, elbow around the objects, or curved; ends dragged to re-attach), marker, highlighter and eraser, stamps, text and sections, five built-in templates and tables, a shared timer and voting sessions, in live sessions (M12); missing: timer music, board widgets, mind maps, washi tape, audio, quick-create handles, connector waypoints, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 20–44 | | | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | | | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 | @@ -134,9 +134,9 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | | Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech (none reviewed by a native speaker; measured 2026-10-17, the drafts cover 95–99% of the 2,272 current strings), Swedish and Turkish catalogs, CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | -| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | +| Ecosystem/plugins | 3 | 40% | 6–22 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), text input and a property menu (API 4), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~53%** | **~262–519** | | | +| **Ready for real work** | 100 | **~53%** | **~260–515** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -517,11 +517,12 @@ Hours in this narrative are the builders' estimates; the tables above supersede a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of - several plugins side by side and the colour picker on plugin colour fields. Then a plugin + several plugins side by side and the colour picker on plugin colour fields; then widget fields + and property menus (API 4: `widget.setText`, `widget.menu`). Then a plugin directory on `figcraft-server`: bundles published with the server key and checked in full, browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, downloads checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher - accounts and signatures; richer widget events; the directory in the browser. 8–26 h. + accounts and signatures; widget hover and timers; the directory in the browser. 6–22 h. 9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer pages: engine, render and headless UI-frame timings). The canvas keeps its last image and re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders @@ -643,6 +644,9 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| | 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | +| 2026-10-24 | minor | Boards (area 22, weight 0): a shared timer and voting sessions (M12); ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; weighted numbers unchanged | +| 2026-10-24 | minor | Boards (M12): templates (five built in, `board.template`) and tables (`board.table`, `board.tableEdit`); area 22 ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h (outside the weighted sum) | +| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | diff --git a/plugins/examples/counter/figcraft-plugin.json b/plugins/examples/counter/figcraft-plugin.json index a046ebc5..fa83c414 100644 --- a/plugins/examples/counter/figcraft-plugin.json +++ b/plugins/examples/counter/figcraft-plugin.json @@ -1,10 +1,10 @@ { "id": "org.figcraft.examples.counter", "name": "Counter", - "version": "1.0.0", - "description": "A widget that counts on the canvas: click its + and − buttons. The count is kept in the document.", + "version": "1.1.0", + "description": "A widget that counts on the canvas: click its + and − buttons, double-click its label to edit it, and use its menu to reset it, count in tens or colour it. Everything is kept in the document.", "main": "counter.wasm", - "api": 3, + "api": 4, "widget": true, "permissions": { "access": "read" } } diff --git a/plugins/examples/counter/src/lib.rs b/plugins/examples/counter/src/lib.rs index d149a53d..92815c4f 100644 --- a/plugins/examples/counter/src/lib.rs +++ b/plugins/examples/counter/src/lib.rs @@ -1,19 +1,53 @@ -//! Counter: a widget on the canvas with − and + buttons around a count. Each click calls the -//! plugin back with the widget's state; it hands back the new count and the redrawn widget. +//! Counter: a widget on the canvas with − and + buttons around a count, a label you can edit on the +//! canvas, and a property menu (reset, steps of ten, a colour). Each click, edit or menu choice +//! calls the plugin back with the widget's state; it hands back the new state and the redrawn +//! widget. -use figcraft_plugin_sdk::{Input, Result, Value, entry, json, render_widget}; +use figcraft_plugin_sdk::{Input, Result, Value, Widget, entry, json, render_widget}; /// The count stays within ± this. const LIMIT: i64 = 999_999; +/// The colours the menu offers for the count (the first is the default). +const COLORS: [&str; 4] = ["#1E1E1E", "#0D99FF", "#14AE5C", "#F24822"]; +/// Shown in the label field while it's empty. +const PLACEHOLDER: &str = "Add a label"; -/// The count after clicking `target` (none: the widget was just inserted). -fn next(count: i64, target: Option<&str>) -> i64 { - let n = match target { - Some("increment") => count.saturating_add(1), - Some("decrement") => count.saturating_sub(1), - _ => count, - }; - n.clamp(-LIMIT, LIMIT) +/// What the widget shows. +#[derive(Debug, PartialEq)] +struct Counter { + count: i64, + big: bool, + color: String, +} + +impl Counter { + fn from_state(state: &Value) -> Counter { + let color = state.get("color").and_then(Value::as_str).filter(|c| COLORS.contains(c)).unwrap_or(COLORS[0]); + Counter { + count: state.get("count").and_then(Value::as_i64).unwrap_or(0), + big: state.get("big").and_then(Value::as_bool).unwrap_or(false), + color: color.into(), + } + } + + /// After the click, edit or menu choice `w` brings (nothing: the widget was just inserted). + fn next(mut self, w: &Widget) -> Counter { + let step = if self.big { 10 } else { 1 }; + match (w.target.as_deref(), w.menu.as_deref()) { + (Some("increment"), _) => self.count = self.count.saturating_add(step), + (Some("decrement"), _) => self.count = self.count.saturating_sub(step), + (_, Some("reset")) => self.count = 0, + (_, Some("big")) => self.big = w.value.as_bool().unwrap_or(!self.big), + (_, Some("color")) => { + if let Some(c) = w.value.as_str().filter(|c| COLORS.contains(c)) { + self.color = c.into(); + } + } + _ => {} + } + self.count = self.count.clamp(-LIMIT, LIMIT); + self + } } /// A square button that calls back with `target`. @@ -22,21 +56,38 @@ fn button(target: &str, name: &str, label: &str) -> Value { "children": [{"type": "text", "text": label, "size": 16, "bold": true, "color": "#1E1E1E"}]}) } -/// The widget showing `count`. -fn view(count: i64) -> Value { - json!({"type": "frame", "name": "Counter", "layout": "horizontal", "gap": 12, "padding": 12, "radius": 12, +/// The widget showing `c` under `label`. +fn view(c: &Counter, label: &str) -> Value { + let (text, ink) = if label.trim().is_empty() { (PLACEHOLDER, "#B3B3B3") } else { (label, "#757575") }; + let (minus, plus) = if c.big { ("−10", "+10") } else { ("−", "+") }; + json!({"type": "frame", "name": "Counter", "layout": "vertical", "gap": 6, "padding": 12, "radius": 12, "align": "min", "fill": "#FFFFFF", "stroke": "#D9D9D9", "children": [ - button("decrement", "Decrease", "−"), - {"type": "text", "name": "Count", "text": count.to_string(), "size": 24, "bold": true, "color": "#1E1E1E"}, - button("increment", "Increase", "+"), + {"type": "text", "name": "Label", "field": "label", "text": text, "size": 12, "color": ink}, + {"type": "frame", "name": "Row", "layout": "horizontal", "gap": 12, "children": [ + button("decrement", "Decrease", minus), + {"type": "text", "name": "Count", "text": c.count.to_string(), "size": 24, "bold": true, "color": c.color}, + button("increment", "Increase", plus), + ]}, ]}) } +/// The property menu for `c`. +fn menu(c: &Counter) -> Value { + json!([ + {"item": "reset", "label": "Reset", "type": "action"}, + {"item": "big", "label": "Steps of 10", "type": "toggle", "checked": c.big}, + {"item": "color", "label": "Colour", "type": "color", "options": COLORS, "value": c.color}, + ]) +} + fn run(input: Input) -> Result { let w = input.widget.ok_or("Insert the counter from Plugins ▸ Widgets")?; - let count = next(w.state.get("count").and_then(Value::as_i64).unwrap_or(0), w.target.as_deref()); - render_widget(&json!({"state": {"count": count}, "root": view(count)}))?; - Ok(json!({"count": count})) + let c = Counter::from_state(&w.state).next(&w); + // An edited label is in the state already, under its field's name. + let label = w.state.get("label").and_then(Value::as_str).unwrap_or("Counter").to_string(); + let state = json!({"count": c.count, "big": c.big, "color": c.color, "label": label}); + render_widget(&json!({"state": state, "root": view(&c, &label), "menu": menu(&c)}))?; + Ok(json!({"count": c.count})) } entry!(run); @@ -45,15 +96,28 @@ entry!(run); mod tests { use super::*; + fn widget(target: Option<&str>, menu: Option<&str>, value: Value) -> Widget { + Widget { target: target.map(Into::into), menu: menu.map(Into::into), value, ..Widget::default() } + } + #[test] - fn counts_within_its_limit() { - assert_eq!(next(0, None), 0); - assert_eq!(next(4, Some("increment")), 5); - assert_eq!(next(4, Some("decrement")), 3); - assert_eq!(next(LIMIT, Some("increment")), LIMIT); - assert_eq!(next(i64::MIN, Some("decrement")), -LIMIT); - assert_eq!(next(2, Some("other")), 2); - assert_eq!(view(3)["children"][1]["text"], "3"); + fn counts_within_its_limit_and_follows_its_menu() { + let c = |count: i64| Counter { count, big: false, color: COLORS[0].into() }; + let at = |n: i64, t: &str| c(n).next(&widget(Some(t), None, Value::Null)).count; + assert_eq!(c(0).next(&Widget::default()).count, 0); + assert_eq!((at(4, "increment"), at(4, "decrement")), (5, 3)); + assert_eq!((at(LIMIT, "increment"), at(i64::MIN, "decrement"), at(2, "other")), (LIMIT, -LIMIT, 2)); + assert_eq!(c(7).next(&widget(None, Some("reset"), Value::Null)).count, 0); + let big = c(1).next(&widget(None, Some("big"), json!(true))); + assert!(big.big); + assert_eq!(big.next(&widget(Some("increment"), None, Value::Null)).count, 11, "steps of ten"); + assert_eq!(c(1).next(&widget(None, Some("color"), json!("#14AE5C"))).color, "#14AE5C"); + assert_eq!(c(1).next(&widget(None, Some("color"), json!("#123456"))).color, COLORS[0], "only the colours offered"); + assert_eq!(Counter::from_state(&json!({"count": 3, "color": "junk"})), c(3)); + let v = view(&c(3), "Score"); + assert_eq!((v["children"][0]["text"].clone(), v["children"][1]["children"][1]["text"].clone()), (json!("Score"), json!("3"))); + assert_eq!(view(&c(3), " ")["children"][0]["text"], PLACEHOLDER); + assert_eq!(menu(&c(0))[1]["checked"], false); assert!(run(Input::default()).is_err(), "it only runs as a widget"); } } diff --git a/plugins/sdk/src/lib.rs b/plugins/sdk/src/lib.rs index 65f70f8d..be2a3208 100644 --- a/plugins/sdk/src/lib.rs +++ b/plugins/sdk/src/lib.rs @@ -27,6 +27,10 @@ //! each time a part of it named as a `target` is clicked, the plugin is called with //! [`Input::widget`] set (its state and what was clicked) and hands back the new state and the //! layers with [`render_widget`]. Each call starts afresh: the state is all a widget remembers. +//! With API 4 a widget can also have editable text fields (a text layer with a `field` name; an +//! edit calls back with [`Widget::field`] and [`Widget::value`], and the value is already in the +//! state under the field's name) and a property menu (`menu` beside `root`; choosing an item calls +//! back with [`Widget::menu`] and, for a toggle or a colour, [`Widget::value`]). //! //! The SDK is `no_std` (it needs `alloc`); outside WebAssembly every host call fails, so a plugin's //! own logic can still be unit-tested on the host. See `docs/plugins.md` for the ABI underneath. @@ -59,7 +63,7 @@ pub struct Input { pub widget: Option, } -/// A widget being inserted (no `target`) or clicked. +/// A widget being inserted (no `target`, `field` or `menu`), clicked, edited or given a menu item. #[derive(Clone, Debug, Default, PartialEq)] pub struct Widget { /// The widget's layer id. @@ -68,6 +72,13 @@ pub struct Widget { pub state: Value, /// The target clicked. pub target: Option, + /// API 4: the editable field edited. + pub field: Option, + /// API 4: the property menu item chosen. + pub menu: Option, + /// API 4: the field's new text, a toggle's new state (`true`/`false`) or the colour chosen + /// (null for a click or an action). + pub value: Value, } /// A button pressed or a value changed in the plugin's panel. @@ -94,10 +105,17 @@ impl Input { value: e.get("value").cloned().unwrap_or(Value::Null), values: v.get("values").cloned().unwrap_or(Value::Null), }), - widget: v.get("widget").map(|w| Widget { - id: w.get("id").and_then(Value::as_u64).unwrap_or_default(), - state: w.get("state").cloned().unwrap_or_else(|| json!({})), - target: w.get("event").and_then(|e| e.get("target")).and_then(Value::as_str).map(ToString::to_string), + widget: v.get("widget").map(|w| { + let event = |key: &str| w.get("event").and_then(|e| e.get(key)); + let name = |key: &str| event(key).and_then(Value::as_str).map(ToString::to_string); + Widget { + id: w.get("id").and_then(Value::as_u64).unwrap_or_default(), + state: w.get("state").cloned().unwrap_or_else(|| json!({})), + target: name("target"), + field: name("field"), + menu: name("menu"), + value: event("value").cloned().unwrap_or(Value::Null), + } }), }) } @@ -360,5 +378,13 @@ mod tests { .widget .unwrap_or_default(); assert_eq!((w.id, &w.state, w.target.as_deref()), (9, &json!({"n": 1}), Some("inc"))); + let e = + Input::parse(r#"{"widget":{"id":9,"state":{},"event":{"field":"label","value":"Hi"}}}"#).unwrap_or_default().widget.unwrap_or_default(); + assert_eq!((e.field.as_deref(), &e.value, e.target.is_none()), (Some("label"), &json!("Hi"), true)); + let m = Input::parse(r##"{"widget":{"id":9,"state":{},"event":{"menu":"color","value":"#FF0000"}}}"##) + .unwrap_or_default() + .widget + .unwrap_or_default(); + assert_eq!((m.menu.as_deref(), &m.value), (Some("color"), &json!("#FF0000"))); } }