Compare commits

...

2 Commits

Author SHA1 Message Date
bflatastic e1cd613b1d Merge remote-tracking branch 'origin/main' into feat/spell-check
# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/roadmap.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
2026-10-11 10:22:24 +07:00
bflatastic 5bebfa589d M4: spell check while editing text
Misspelled words in the text being edited get a wavy underline (theme
danger colour); right-click one for suggestions, Ignore (until the app
quits) and Add to dictionary (saved in preferences). Text > Spelling >
Check spelling toggles it, on by default, with a check mark.

The engine stays platform-free: a SpellChecker trait the desktop app
installs (the system's checker through os-spellcheck on Windows and
macOS, in the system's language). Words are checked one at a time and
cached; the edited text is re-checked only when the document changes.
Linux, FreeBSD and the web have no checker yet; scripts written without
spaces (CJK, Thai) are skipped.

Commands: text.spelling, text.spellingSuggestions, text.replaceWord,
spelling.addWord, spelling.ignore. Menu breadth 201/204.
2026-10-11 10:20:35 +07:00
19 changed files with 582 additions and 46 deletions

88
Cargo.lock generated
View File

@ -1408,6 +1408,7 @@ dependencies = [
"figcraft-ui-egui", "figcraft-ui-egui",
"image", "image",
"log", "log",
"os-spellcheck",
"rfd", "rfd",
"serde_json", "serde_json",
"webbrowser", "webbrowser",
@ -2812,8 +2813,8 @@ dependencies = [
"block2 0.5.1", "block2 0.5.1",
"libc", "libc",
"objc2 0.5.3", "objc2 0.5.3",
"objc2-core-data", "objc2-core-data 0.2.2",
"objc2-core-image", "objc2-core-image 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-quartz-core 0.2.2", "objc2-quartz-core 0.2.2",
] ]
@ -2826,10 +2827,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2", "block2 0.6.2",
"libc",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-cloud-kit 0.3.2",
"objc2-core-data 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
"objc2-core-image 0.3.2",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
] ]
[[package]] [[package]]
@ -2845,6 +2853,17 @@ dependencies = [
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "objc2-cloud-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-contacts" name = "objc2-contacts"
version = "0.2.2" version = "0.2.2"
@ -2868,6 +2887,17 @@ dependencies = [
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "objc2-core-data"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-core-foundation" name = "objc2-core-foundation"
version = "0.3.2" version = "0.3.2"
@ -2904,6 +2934,16 @@ dependencies = [
"objc2-metal 0.2.2", "objc2-metal 0.2.2",
] ]
[[package]]
name = "objc2-core-image"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
dependencies = [
"objc2 0.6.5",
"objc2-foundation 0.3.2",
]
[[package]] [[package]]
name = "objc2-core-location" name = "objc2-core-location"
version = "0.2.2" version = "0.2.2"
@ -2916,6 +2956,31 @@ dependencies = [
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.13.2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
@ -2943,6 +3008,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.6.2", "block2 0.6.2",
"libc",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@ -3040,9 +3106,9 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"objc2 0.5.3", "objc2 0.5.3",
"objc2-cloud-kit", "objc2-cloud-kit 0.2.2",
"objc2-core-data", "objc2-core-data 0.2.2",
"objc2-core-image", "objc2-core-image 0.2.2",
"objc2-core-location", "objc2-core-location",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
"objc2-link-presentation", "objc2-link-presentation",
@ -3129,6 +3195,18 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "os-spellcheck"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0062960325585b13f5162fb61d236054ab5c300071d5e49dfcc37c98338b120c"
dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"windows",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) **Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, 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 native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind | | | Value | Kind |
|---|---|---| |---|---|---|
| Menu/command breadth | **98.5%** (200 / 203) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | | Menu/command breadth | **98.5%** (201 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) | | Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) |
| Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
@ -26,7 +26,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---| |---|---|---|---|
| Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | | Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (91–177), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (88–173), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@ -56,7 +56,7 @@ the localization layer with Figma's own languages (20–35 h).
| Dimension | Ready | Hours | Doc | | Dimension | Ready | Hours | Doc |
|---|---|---|---| |---|---|---|---|
| Features (depth) | 57% (core editor 67%) | 71–135 | [target-app-parity](docs/target-app-parity.md) | | Features (depth) | 57% (core editor 67%) | 68–131 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) | | File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) | | Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) |
@ -81,7 +81,7 @@ the localization layer with Figma's own languages (20–35 h).
| Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) | | Effects | 100% | 72% | 3–6 | [target-app-parity](docs/target-app-parity.md) |
| Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 95% | 75% | 2–4 | [target-app-parity](docs/target-app-parity.md) |
| Auto layout & constraints | 95% | 68% | 6–12 | [auto-layout-parity](docs/auto-layout-parity.md) | | Auto layout & constraints | 95% | 68% | 6–12 | [auto-layout-parity](docs/auto-layout-parity.md) |
| Text | 85% | 64% | 8–14 | [target-app-parity](docs/target-app-parity.md) | | Text | 88% | 66% | 5–10 | [target-app-parity](docs/target-app-parity.md) |
| Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) | | Components & variants | 95% | 68% | 6–12 | [components-parity](docs/components-parity.md) |
| Styles & variables | 85% | 70% | 4–8 | [components-parity](docs/components-parity.md) | | Styles & variables | 85% | 70% | 4–8 | [components-parity](docs/components-parity.md) |
| Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) | | Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) |
@ -157,6 +157,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |

View File

@ -29,5 +29,9 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement"] } windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement"] }
# The system's spell checker (ISpellChecker, NSSpellChecker).
[target.'cfg(any(windows, target_os = "macos"))'.dependencies]
os-spellcheck = "0.2"
[build-dependencies] [build-dependencies]
winresource = "0.1" winresource = "0.1"

View File

@ -120,6 +120,12 @@ fn main() -> eframe::Result {
// default, never a build from source unless asked). // default, never a build from source unless asked).
session.live.links = figcraft_collab_net::links::system(); session.live.links = figcraft_collab_net::links::system();
session.invite_links_startup(); session.invite_links_startup();
// shortcut: no spell check on Linux and FreeBSD (their checkers are C libraries), add a
// hunspell/enchant checker when Linux users ask.
#[cfg(any(windows, target_os = "macos"))]
{
session.spelling.checker = Some(Box::new(services::SystemSpelling::default()));
}
if session.docs.is_empty() { if session.docs.is_empty() {
let _ = session.execute("file.new", &json!({})); let _ = session.execute("file.new", &json!({}));
} }

View File

@ -160,6 +160,22 @@ impl Services for Desktop {
} }
} }
/// The system's spell checker, connected on first use from the UI thread (on Windows, COM is set
/// up there once the window exists).
#[cfg(any(windows, target_os = "macos"))]
#[derive(Default)]
pub struct SystemSpelling(Option<os_spellcheck::SpellChecker>);
#[cfg(any(windows, target_os = "macos"))]
impl figcraft_engine::spelling::SpellChecker for SystemSpelling {
fn misspelled(&mut self, word: &str) -> bool {
!self.0.get_or_insert_with(os_spellcheck::SpellChecker::new).check(word).is_empty()
}
fn suggest(&mut self, word: &str) -> Vec<String> {
self.0.get_or_insert_with(os_spellcheck::SpellChecker::new).suggestions(word)
}
}
/// The window icon from the bundled app icon (if present). /// The window icon from the bundled app icon (if present).
pub fn window_icon() -> Option<egui::IconData> { pub fn window_icon() -> Option<egui::IconData> {
let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png"); let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png");

View File

@ -198,6 +198,8 @@ pub static MENU: &[MenuItem] = &[
m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Uppercase", "text.caseUpper"),
m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Lowercase", "text.caseLower"),
m!("Text/Case", "Title case", "text.caseTitle"), m!("Text/Case", "Title case", "text.caseTitle"),
m!("Text", SEP, ""),
m!("Text/Spelling", "Check spelling", "text.spelling"),
// Arrange // Arrange
m!("Arrange", "Align left", "align.left"), m!("Arrange", "Align left", "align.left"),
m!("Arrange", "Align horizontal centers", "align.hcenter"), m!("Arrange", "Align horizontal centers", "align.hcenter"),

View File

@ -97,6 +97,7 @@ fn registry() -> &'static Registry {
list.extend(crate::plugins::specs()); list.extend(crate::plugins::specs());
list.extend(crate::live::specs()); list.extend(crate::live::specs());
list.extend(crate::invite_links::specs()); list.extend(crate::invite_links::specs());
list.extend(crate::spelling::specs());
let mut index = HashMap::new(); let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() { for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() { if index.insert(s.id, i).is_some() {

View File

@ -34,6 +34,7 @@ pub mod plugins;
pub mod proto_run; pub mod proto_run;
pub mod shapebuilder; pub mod shapebuilder;
pub mod snap; pub mod snap;
pub mod spelling;
pub mod synthetic; pub mod synthetic;
pub mod synthetic_ds; pub mod synthetic_ds;
pub mod textedit; pub mod textedit;
@ -456,6 +457,10 @@ pub struct Prefs {
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
/// builds, off for development builds. /// builds, off for development builds.
pub invite_links: Option<bool>, pub invite_links: Option<bool>,
/// Underline misspelled words while editing text (`text.spelling`).
pub spell_check: bool,
/// Words never flagged as misspelled (`spelling.addWord`), lowercased.
pub dictionary: Vec<String>,
/// Installed plugins (Plugins menu), in menu order. /// Installed plugins (Plugins menu), in menu order.
#[serde(serialize_with = "plugins::persisted")] #[serde(serialize_with = "plugins::persisted")]
pub plugins: Vec<plugins::InstalledPlugin>, pub plugins: Vec<plugins::InstalledPlugin>,
@ -475,6 +480,8 @@ impl Default for Prefs {
user_name: cmd::comment::DEFAULT_AUTHOR.into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false, reduce_motion: false,
invite_links: None, invite_links: None,
spell_check: true,
dictionary: Vec::new(),
plugins: Vec::new(), plugins: Vec::new(),
} }
} }
@ -526,6 +533,8 @@ pub struct Session {
pub plugins: plugins::Runtime, pub plugins: plugins::Runtime,
/// The live session (collaboration): status, peers, presence. /// The live session (collaboration): status, peers, presence.
pub live: live::Live, pub live: live::Live,
/// Spell check while editing text.
pub spelling: spelling::Spelling,
} }
impl Default for Session { impl Default for Session {
@ -558,6 +567,7 @@ impl Session {
libraries: library::Cache::default(), libraries: library::Cache::default(),
plugins: plugins::Runtime::default(), plugins: plugins::Runtime::default(),
live: live::Live::default(), live: live::Live::default(),
spelling: spelling::Spelling::default(),
} }
} }
@ -1243,6 +1253,8 @@ mod shapebuilder_tests;
#[cfg(test)] #[cfg(test)]
mod slot_tests; mod slot_tests;
#[cfg(test)] #[cfg(test)]
mod spelling_tests;
#[cfg(test)]
mod style_tests; mod style_tests;
#[cfg(test)] #[cfg(test)]
mod svg_import_tests; mod svg_import_tests;

View File

@ -0,0 +1,222 @@
//! Spell check while editing text (milestone M4): misspelled words in the text being edited are
//! underlined, and a flagged word's context menu offers suggestions, Ignore and Add to dictionary.
//!
//! The engine stays platform-free: the desktop app installs the operating system's checker as a
//! [`SpellChecker`] (like the [`LiveLink`](crate::live::LiveLink)). Words are checked one at a
//! time and cached, and the edited text's flagged words are worked out again only when the
//! document changes ([`Session::sync_spelling`]), never per frame.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use figcraft_doc::{Document, NodeId};
use serde_json::{Value, json};
use crate::cmd::{CommandSpec, ok};
use crate::params::{boolean, req_index, req_string};
use crate::{EngineError, Result, Session, bad, spec, textedit};
/// The system's spell checker (the desktop app installs one: `session.spelling.checker`).
// shortcut: one language, the system's (no Text ▸ Spelling ▸ Language), add a language list to
// this trait when someone needs to check text in another language.
pub trait SpellChecker {
/// Whether `word` (one word, no spaces) is misspelled.
fn misspelled(&mut self, word: &str) -> bool;
/// Replacements for a misspelled `word`, best first.
fn suggest(&mut self, word: &str) -> Vec<String>;
}
/// Spell-check state: the checker, its answers so far and the words ignored until the app quits.
#[derive(Default)]
pub struct Spelling {
pub checker: Option<Box<dyn SpellChecker>>,
/// Word → misspelled, as the checker answered.
known: HashMap<String, bool>,
/// Lowercased words ignored for this run of the app.
ignored: HashSet<String>,
/// The flagged words of text layer `NodeId` in that document.
flagged: Option<(Arc<Document>, NodeId, Ranges)>,
}
/// Char ranges.
type Ranges = Vec<(usize, usize)>;
/// Most suggestions offered for a word.
const MAX_SUGGESTIONS: usize = 5;
/// Longest word added to the dictionary, and most words in it (and in the checker's cache).
const MAX_WORD: usize = 64;
const MAX_DICTIONARY: usize = 10_000;
/// Scripts written without spaces between words: CJK, kana, Thai, Lao, Myanmar, Khmer.
// shortcut: text in these scripts is never checked (a run of it isn't one word, and the system
// checkers flag whole runs too), split it with a word breaker when someone needs it checked.
fn unspaced(c: char) -> bool {
matches!(c as u32, 0x0E00..=0x0EFF | 0x1000..=0x109F | 0x1780..=0x17FF | 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFF66..=0xFF9F | 0x20000..)
}
/// The words of `chars` as char ranges: letters and digits, with apostrophes inside a word
/// ("don't").
fn words(chars: &[char]) -> Vec<(usize, usize)> {
let letter = |i: usize| chars.get(i).is_some_and(|c| c.is_alphanumeric() && !unspaced(*c));
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
if !letter(i) {
i += 1;
continue;
}
let a = i;
while letter(i) || (matches!(chars.get(i), Some('\'' | '\u{2019}')) && letter(i + 1)) {
i += 1;
}
out.push((a, i));
}
out
}
impl Session {
/// Whether `word` should be flagged: the checker says so, and it isn't a number, ignored or in
/// the dictionary.
fn is_misspelled(&mut self, word: &str) -> bool {
if word.chars().any(char::is_numeric) {
return false;
}
let lower = word.to_lowercase();
if self.prefs.dictionary.contains(&lower) || self.spelling.ignored.contains(&lower) {
return false;
}
let sp = &mut self.spelling;
let Some(checker) = sp.checker.as_mut() else { return false };
if let Some(m) = sp.known.get(word) {
return *m;
}
if sp.known.len() >= MAX_DICTIONARY {
sp.known.clear();
}
let m = checker.misspelled(word);
sp.known.insert(word.to_string(), m);
m
}
/// Work out the edited text's misspelled words again if the document changed (the UI calls
/// it before drawing; cheap otherwise).
pub fn sync_spelling(&mut self) {
let edited = self.ds().ok().and_then(|ds| Some((ds.doc.clone(), ds.text_edit.as_ref()?.id)));
let Some((doc, id)) = edited.filter(|_| self.prefs.spell_check && self.spelling.checker.is_some()) else {
self.spelling.flagged = None;
return;
};
if self.spelling.flagged.as_ref().is_some_and(|(d, i, _)| Arc::ptr_eq(d, &doc) && *i == id) {
return;
}
let chars: Vec<char> = doc.node(id).and_then(|n| n.kind.text()).map(|t| t.characters.chars().collect()).unwrap_or_default();
let flagged = words(&chars)
.into_iter()
.filter(|(a, b)| {
let word: String = chars.get(*a..*b).unwrap_or_default().iter().collect();
self.is_misspelled(&word)
})
.collect();
self.spelling.flagged = Some((doc, id, flagged));
}
/// The misspelled words (char ranges) of the text being edited, as of the last
/// [`Session::sync_spelling`].
pub fn misspelled(&self) -> &[(usize, usize)] {
self.spelling.flagged.as_ref().map_or(&[], |(_, _, f)| f)
}
/// The word at char `index` of the text being edited (an index just after a word counts):
/// its char range and text.
fn word_at(&self, cmd: &str, index: usize) -> Result<Option<(usize, usize, String)>> {
let te = self.ds()?.text_edit.clone().ok_or_else(|| EngineError::Other(format!("{cmd}: not editing text")))?;
let chars: Vec<char> = self.doc()?.node(te.id).and_then(|n| n.kind.text()).map(|t| t.characters.chars().collect()).unwrap_or_default();
Ok(words(&chars)
.into_iter()
.find(|(a, b)| (*a..=*b).contains(&index))
.map(|(a, b)| (a, b, chars.get(a..b).unwrap_or_default().iter().collect())))
}
}
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let on = boolean(p, "on").unwrap_or(!s.prefs.spell_check);
s.prefs.spell_check = on;
s.spelling.flagged = None;
Ok(json!({"on": on, "available": s.spelling.checker.is_some()}))
}
fn suggestions(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "text.spellingSuggestions";
let Some((a, b, word)) = s.word_at(CMD, req_index(CMD, p, "index")?)? else { return Ok(Value::Null) };
let misspelled = s.is_misspelled(&word);
let mut suggestions = match s.spelling.checker.as_mut() {
Some(c) if misspelled => c.suggest(&word),
_ => Vec::new(),
};
suggestions.truncate(MAX_SUGGESTIONS);
Ok(json!({"word": word, "start": a, "end": b, "misspelled": misspelled, "suggestions": suggestions}))
}
fn replace_word(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "text.replaceWord";
let with = req_string(CMD, p, "with")?;
if with.is_empty() {
return Err(bad(CMD, "`with` can't be empty"));
}
let (a, b, _) = s.word_at(CMD, req_index(CMD, p, "index")?)?.ok_or_else(|| bad(CMD, "no word at `index`"))?;
textedit::set_caret(s, a, false)?;
textedit::set_caret(s, b, true)?;
textedit::insert(s, &with)?;
ok()
}
/// A word from params, lowercased: one word of reasonable length.
fn word_param(cmd: &str, p: &Value) -> Result<String> {
let w = req_string(cmd, p, "word")?.trim().to_lowercase();
if w.is_empty() || w.chars().count() > MAX_WORD || w.chars().any(char::is_whitespace) {
return Err(bad(cmd, format!("`word` must be one word of at most {MAX_WORD} characters")));
}
Ok(w)
}
fn add_word(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "spelling.addWord";
let w = word_param(CMD, p)?;
if !s.prefs.dictionary.contains(&w) {
if s.prefs.dictionary.len() >= MAX_DICTIONARY {
return Err(bad(CMD, format!("the dictionary is full ({MAX_DICTIONARY} words)")));
}
s.prefs.dictionary.push(w);
}
s.spelling.flagged = None;
ok()
}
fn ignore(s: &mut Session, p: &Value) -> Result<Value> {
let w = word_param("spelling.ignore", p)?;
s.spelling.ignored.insert(w);
s.spelling.flagged = None;
ok()
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"text.spelling",
"Check spelling",
"{on? (default: switch)} → {on, available}: underline misspelled words while editing text (the system's spell checker; `available` is false where there is none)",
false,
toggle
),
spec!(
"text.spellingSuggestions",
"Spelling suggestions",
"{index (char)} → {word, start, end, misspelled, suggestions} for the word at `index` of the text being edited, or null",
false,
suggestions
),
spec!("text.replaceWord", "Replace word", "{index (char), with}: replace the word at `index` of the text being edited", true, replace_word),
spec!("spelling.addWord", "Add to dictionary", "{word}: never flag this word (saved in preferences)", false, add_word),
spec!("spelling.ignore", "Ignore", "{word}: don't flag this word until the app quits", false, ignore),
]
}

View File

@ -0,0 +1,92 @@
//! Spell check while editing text, against a fake checker (tests never touch the system's).
use std::sync::{Arc, Mutex};
use serde_json::json;
use crate::Session;
use crate::params::id_of;
use crate::spelling::SpellChecker;
/// Knows a few words; counts how often it's asked.
struct Fake(Arc<Mutex<usize>>);
impl SpellChecker for Fake {
fn misspelled(&mut self, word: &str) -> bool {
*self.0.lock().unwrap() += 1;
!["Hello", "world", "don't", "the"].contains(&word)
}
fn suggest(&mut self, word: &str) -> Vec<String> {
vec![format!("{word}!"), "world".into()]
}
}
/// Editing a text layer "Hello wrold, don't Helo 42" with the fake checker installed.
fn editing() -> (Session, Arc<Mutex<usize>>) {
let asked = Arc::new(Mutex::new(0));
let mut s = Session::new();
s.spelling.checker = Some(Box::new(Fake(asked.clone())));
let t = id_of(&s.execute("text.create", &json!({"text": "Hello wrold, don't Helo 42", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
s.execute("text.edit.begin", &json!({"id": t, "caret": 0})).unwrap();
s.sync_spelling();
(s, asked)
}
#[test]
fn misspelled_words_are_flagged_and_cached() {
let (mut s, asked) = editing();
// "wrold" and "Helo"; the apostrophe stays inside "don't" and numbers aren't words to check.
assert_eq!(s.misspelled(), &[(6, 11), (19, 23)]);
let n = *asked.lock().unwrap();
s.sync_spelling();
assert_eq!(*asked.lock().unwrap(), n, "nothing changed: nothing checked again");
s.execute("text.edit.key", &json!({"key": "End"})).unwrap();
s.execute("text.edit.insert", &json!({"text": " Hello"})).unwrap();
s.sync_spelling();
assert_eq!(*asked.lock().unwrap(), n, "known words come from the cache");
s.execute("text.spelling", &json!({"on": false})).unwrap();
s.sync_spelling();
assert!(s.misspelled().is_empty(), "switched off");
assert!(!s.prefs.spell_check);
}
#[test]
fn scripts_without_spaces_are_not_checked() {
let (mut s, _) = editing();
s.execute("text.edit.key", &json!({"key": "End"})).unwrap();
s.execute("text.edit.insert", &json!({"text": " 这是中文 สวัสดี Привет"})).unwrap();
s.sync_spelling();
// CJK and Thai runs aren't single words; Cyrillic is checked like any other spaced script.
assert_eq!(s.misspelled(), &[(6, 11), (19, 23), (39, 45)]);
}
#[test]
fn suggestions_replace_the_word_in_one_undo_step() {
let (mut s, _) = editing();
let r = s.execute("text.spellingSuggestions", &json!({"index": 8})).unwrap();
assert_eq!(r, json!({"word": "wrold", "start": 6, "end": 11, "misspelled": true, "suggestions": ["wrold!", "world"]}));
let ok = s.execute("text.spellingSuggestions", &json!({"index": 0})).unwrap();
assert_eq!(ok["misspelled"], json!(false));
assert_eq!(ok["suggestions"], json!([]));
s.execute("text.replaceWord", &json!({"index": 11, "with": "world"})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
let text = |s: &Session| s.doc().unwrap().node(s.selection()[0]).unwrap().kind.text().unwrap().characters.clone();
assert_eq!(text(&s), "Hello world, don't Helo 42");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s), "Hello wrold, don't Helo 42");
// Not editing: nothing to replace.
assert!(s.execute("text.replaceWord", &json!({"index": 0, "with": "x"})).is_err());
}
#[test]
fn ignore_and_add_to_dictionary() {
let (mut s, _) = editing();
s.execute("spelling.ignore", &json!({"word": "wrold"})).unwrap();
s.sync_spelling();
assert_eq!(s.misspelled(), &[(19, 23)]);
s.execute("spelling.addWord", &json!({"word": "Helo"})).unwrap();
s.sync_spelling();
assert!(s.misspelled().is_empty());
assert_eq!(s.prefs.dictionary, vec!["helo".to_string()], "saved lowercased in preferences");
assert!(s.execute("spelling.addWord", &json!({"word": "two words"})).is_err());
}

View File

@ -141,6 +141,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
input(app, ui, &resp); input(app, ui, &resp);
crate::live::track_pointer(app, &ctx); crate::live::track_pointer(app, &ctx);
app.sync_preedit(); app.sync_preedit();
app.session.sync_spelling();
request_render(app, worker, ppp); request_render(app, worker, ppp);
// Every finished render is shown, even when a newer one is already on its way: renders finish // Every finished render is shown, even when a newer one is already on its way: renders finish
// in order, and an edit every frame (a drag) would otherwise never show until it stopped. // in order, and an edit every frame (a drag) would otherwise never show until it stopped.
@ -453,6 +454,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) {
/// open the context menu there. /// open the context menu there.
pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
let p = app.to_page(pos); let p = app.to_page(pos);
app.ui.context_spelling = misspelled_at(app, p).map(|(i, w, sugg)| (pos, i, w, sugg));
if let Ok(ds) = app.session.ds() { if let Ok(ds) = app.session.ds() {
let (doc, page, sel, zoom) = (ds.doc.clone(), ds.page, ds.selection.clone(), ds.view.zoom.max(1e-6)); let (doc, page, sel, zoom) = (ds.doc.clone(), ds.page, ds.selection.clone(), ds.view.zoom.max(1e-6));
if let Some(leaf) = app.session.hit(&doc, page, p, 2.0 / zoom) { if let Some(leaf) = app.session.hit(&doc, page, p, 2.0 / zoom) {
@ -465,6 +467,23 @@ pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
app.ui.context_menu = Some((pos, app.session.selection())); app.ui.context_menu = Some((pos, app.session.selection()));
} }
/// The misspelled word at page point `p` of the text being edited: its char index, the word and
/// the checker's suggestions.
fn misspelled_at(app: &mut FigApp, p: Point) -> Option<(usize, String, Vec<String>)> {
let ds = app.session.ds().ok()?;
let id = ds.text_edit.as_ref()?.id;
if !ds.doc.world_bounds(id).is_some_and(|b| b.contains(p)) {
return None;
}
let index = app.session.text_index_at(id, p).ok()?;
let r = app.session.execute("text.spellingSuggestions", &json!({"index": index})).ok()?;
if r["misspelled"] != true {
return None;
}
let suggestions = r["suggestions"].as_array()?.iter().filter_map(|v| v.as_str().map(str::to_string)).collect();
Some((index, r["word"].as_str()?.to_string(), suggestions))
}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn dropped_bytes(f: &std::sync::Arc<dyn egui::DroppedFile + Send + Sync>) -> Option<Vec<u8>> { fn dropped_bytes(f: &std::sync::Arc<dyn egui::DroppedFile + Send + Sync>) -> Option<Vec<u8>> {
f.bytes().ok() f.bytes().ok()
@ -512,6 +531,29 @@ impl TextOnScreen {
rects.iter().map(|r| [self.at(app, r.x0, r.y0), self.at(app, r.x1, r.y0), self.at(app, r.x1, r.y1), self.at(app, r.x0, r.y1)]).collect() rects.iter().map(|r| [self.at(app, r.x0, r.y0), self.at(app, r.x1, r.y0), self.at(app, r.x1, r.y1), self.at(app, r.x0, r.y1)]).collect()
} }
/// A line just below the baseline under characters `a..b` (their glyphs only), one screen
/// segment per line.
pub(crate) fn underline(&self, app: &FigApp, a: usize, b: usize) -> Vec<[Pos2; 2]> {
let layout = &self.layout;
let rects = layout.selection_rects(a, b);
rects
.iter()
.filter_map(|r| {
let line = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6)?;
let x1 = layout
.glyphs
.get(line.glyph_start..line.glyph_end)
.unwrap_or(&[])
.iter()
.filter(|g| g.char_end > g.char_start && g.char_start >= a && g.char_end <= b)
.map(|g| g.x + g.advance)
.fold(r.x0, f64::max);
let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
Some([self.at(app, r.x0, y), self.at(app, x1, y)])
})
.collect()
}
/// The caret before character `index`: its top and bottom on screen (an empty layer: a line /// The caret before character `index`: its top and bottom on screen (an empty layer: a line
/// of default height at its origin). /// of default height at its origin).
pub(crate) fn caret(&self, app: &FigApp, index: usize, zoom: f64) -> [Pos2; 2] { pub(crate) fn caret(&self, app: &FigApp, index: usize, zoom: f64) -> [Pos2; 2] {
@ -537,6 +579,20 @@ pub(crate) fn node_quad(app: &FigApp, doc: &Document, id: NodeId) -> Option<[Pos
})) }))
} }
/// A wavy line along screen segment `[p0, p1]` (misspelled words).
fn squiggle(painter: &egui::Painter, [p0, p1]: [Pos2; 2], col: Color32) {
let d = p1 - p0;
let len = d.length();
if !len.is_finite() || len < 2.0 {
return;
}
let (u, n) = (d / len, vec2(-d.y, d.x) / len);
let steps = ((len / 3.0) as usize).min(4000);
let step = len / steps as f32;
let pts = (0..=steps).map(|i| p0 + u * (i as f32 * step) + n * if i % 2 == 0 { -1.5 } else { 1.5 }).collect();
painter.add(Shape::line(pts, stroke(col, 1.0)));
}
fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) { fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) {
painter.add(Shape::closed_line(q.to_vec(), s)); painter.add(Shape::closed_line(q.to_vec(), s));
} }
@ -830,7 +886,6 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
if let Some(te) = editing if let Some(te) = editing
&& let Some(text) = TextOnScreen::of(edoc, te.id) && let Some(text) = TextOnScreen::of(edoc, te.id)
{ {
let layout = &text.layout;
if let Some(q) = node_quad(app, edoc, te.id) { if let Some(q) = node_quad(app, edoc, te.id) {
quad(painter, q, stroke(ACCENT, 1.0)); quad(painter, q, stroke(ACCENT, 1.0));
} }
@ -841,19 +896,15 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
} }
} }
if let Some(p) = preedit { if let Some(p) = preedit {
for r in layout.selection_rects(p.range.0, p.range.1) { for seg in text.underline(app, p.range.0, p.range.1) {
// Underline just below the baseline, across the composed chars only. painter.line_segment(seg, stroke(t.text, 1.0));
let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue }; }
let x1 = layout } else {
.glyphs // Misspelled words, except the one being typed.
.get(line.glyph_start..line.glyph_end) for &(a, b) in app.session.misspelled().iter().filter(|(a, b)| !(*a..=*b).contains(&te.caret)) {
.unwrap_or(&[]) for seg in text.underline(app, a, b) {
.iter() squiggle(painter, seg, t.danger);
.filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1) }
.map(|g| g.x + g.advance)
.fold(r.x0, f64::max);
let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
} }
} }
let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret); let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);

View File

@ -230,6 +230,9 @@ pub struct UiState {
pub preview: Option<present::Pane>, pub preview: Option<present::Pane>,
pub picker: Option<colorpicker::PickerTarget>, pub picker: Option<colorpicker::PickerTarget>,
pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>, pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
/// The misspelled word the context menu at that position was opened on (while editing text):
/// its char index, the word and the suggestions that head the menu.
pub context_spelling: Option<(egui::Pos2, usize, String, Vec<String>)>,
/// Layers panel search: open, the filter text, the shift-click range anchor and the selection /// Layers panel search: open, the filter text, the shift-click range anchor and the selection
/// the tree last revealed (ancestors expand and the row scrolls into view when it changes). /// the tree last revealed (ancestors expand and the row scrolls into view when it changes).
pub layer_search: bool, pub layer_search: bool,
@ -321,6 +324,7 @@ impl Default for UiState {
preview: None, preview: None,
picker: None, picker: None,
context_menu: None, context_menu: None,
context_spelling: None,
layer_search: false, layer_search: false,
layer_filter: String::new(), layer_filter: String::new(),
layer_anchor: None, layer_anchor: None,

View File

@ -183,7 +183,8 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool {
} }
last_sep = false; last_sep = false;
let enabled = available(m.id); let enabled = available(m.id);
if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled { let checked = m.id == "text.spelling" && app.session.prefs.spell_check;
if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, checked, &t) && enabled {
clicked = Some(m.id); clicked = Some(m.id);
} }
} else if let Some(rest) = m.menu.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) { } else if let Some(rest) = m.menu.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) {
@ -363,6 +364,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
let mut close = false; let mut close = false;
let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { let area = egui::Area::new(Id::new("ctx_menu")).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| {
menu_frame(ui, &t, |ui| { menu_frame(ui, &t, |ui| {
if spelling_entries(app, ui, pos, &t) {
close = true;
}
if select_layer_menu(app, ui, pos, &t) { if select_layer_menu(app, ui, pos, &t) {
close = true; close = true;
} }
@ -461,6 +465,30 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
palette(app, ctx); palette(app, ctx);
} }
/// The context menu's entries for the misspelled word it was opened on at `pos`: its suggestions,
/// Ignore and Add to dictionary. Returns whether one was picked.
fn spelling_entries(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) -> bool {
let Some((_, index, word, suggestions)) = app.ui.context_spelling.clone().filter(|c| c.0 == pos) else { return false };
let mut picked = None;
if suggestions.is_empty() {
item(ui, "No suggestions", "", false, false, t);
}
for s in &suggestions {
if item(ui, s, "", true, false, t) {
picked = Some(("text.replaceWord", json!({"index": index, "with": s})));
}
}
separator(ui);
if item(ui, "Ignore", "", true, false, t) {
picked = Some(("spelling.ignore", json!({"word": word})));
}
if item(ui, "Add to dictionary", "", true, false, t) {
picked = Some(("spelling.addWord", json!({"word": word})));
}
separator(ui);
picked.map(|(id, p)| app.run(id, p)).is_some()
}
fn select_layer_id() -> Id { fn select_layer_id() -> Id {
Id::new("ctx_select_layer_open") Id::new("ctx_select_layer_open")
} }

View File

@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma # Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **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 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 doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@ -24,7 +24,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 8 | Platform QA is mostly macOS | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA is mostly macOS | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
| 9 | Figma Draw: brushes, pressure, text on a path, pattern fills | Feature | 10–18 | [target-app-parity](target-app-parity.md) | | 9 | Figma Draw: brushes, pressure, text on a path, pattern fills | Feature | 10–18 | [target-app-parity](target-app-parity.md) |
| 10 | Shared (team) libraries | Feature | 10–20 | [components-parity](components-parity.md) | | 10 | Shared (team) libraries | Feature | 10–20 | [components-parity](components-parity.md) |
| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | | 11 | Spell check on Linux, FreeBSD and the web; one language | Feature | 2–5 | [target-app-parity](target-app-parity.md) |
| 12 | Video and animated GIF fills; HEIC/TIFF images | File format | 6–12 | [file-format-parity](file-format-parity.md) | | 12 | Video and animated GIF fills; HEIC/TIFF images | File format | 6–12 | [file-format-parity](file-format-parity.md) |
| 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | | 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
| 14 | Variables: extended collections, import/export | Feature | 4–8 | [components-parity](components-parity.md) | | 14 | Variables: extended collections, import/export | Feature | 4–8 | [components-parity](components-parity.md) |
@ -153,11 +153,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Impact:** design-system teams. Builds on multiplayer's server. - **Impact:** design-system teams. Builds on multiplayer's server.
- **Estimate:** 10–20 h after gap 2. - **Estimate:** 10–20 h after gap 2.
### 11. Spell check ### 11. Spell check on Linux, FreeBSD and the web; one language
- **Missing:** spelling underline and suggestions while editing text. - **Have:** misspelled words in the text being edited get a wavy underline; right-click one for
- **Evidence:** no spell-check code in the repo. suggestions, Ignore (until the app quits) and Add to dictionary (saved in preferences); Text ▸
- **Estimate:** 4–8 h with a permissively licensed dictionary and checker (provenance per Spelling ▸ Check spelling, on by default (`crates/engine/src/spelling.rs`). The desktop app uses
`ATTRIBUTION.md`). the system's checker on Windows and macOS, in the system's language.
- **Missing:** a checker on Linux and FreeBSD (hunspell or enchant) and in the browser; choosing the
language (Text ▸ Spelling ▸ Language); text in scripts written without spaces (CJK, Thai) isn't
checked; Add to dictionary doesn't reach the system's dictionary.
- **Estimate:** 2–5 h.
### 12. Video and animated GIF fills; HEIC/TIFF images ### 12. Video and animated GIF fills; HEIC/TIFF images
- **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video - **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video
@ -230,6 +234,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |

View File

@ -465,6 +465,17 @@ visibility, text, font properties, single solid fills and strokes) and `explicit
link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a
frame back to Auto, inheriting its parent's mode). frame back to Auto, inheriting its parent's mode).
### Spell check
While a text layer is being edited, the desktop app underlines misspelled words (the system's
checker on Windows and macOS, in the system's language; the headless server and the browser have
none, so nothing is flagged there). `text.spelling {on?}` switches it (saved in preferences;
`available` says whether a checker is installed). With text being edited,
`text.spellingSuggestions {index}` returns the word at char `index` with `misspelled` and up to five
`suggestions` (null between words), and `text.replaceWord {index, with}` replaces that word (part of
the editing session's undo step). `spelling.addWord {word}` never flags a word again (saved in
preferences) and `spelling.ignore {word}` stops flagging it until the app quits.
### Comments ### Comments
Comment threads live in the file, pinned to a page point or to a layer (`anchor`: a node id, or Comment threads live in the file, pinned to a page point or to a layer (`anchor`: a node id, or

View File

@ -2,9 +2,9 @@
> **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand. > **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand.
FigCraft commands registered for the incumbent's main menu and toolbar (455 commands in the engine). FigCraft commands registered for the incumbent's main menu and toolbar (469 commands in the engine).
**200 / 203 menu items (98.5%)** **201 / 204 menu items (98.5%)**
## Missing ## Missing

View File

@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next # FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and 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 [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@ -50,7 +50,7 @@ blockers.
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~75% | Open recent, outline mode variants, keyboard box selection, interaction pass | 8–14 + pass |
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
| M3 Components & styles depth | ~75% | extended collections, variables import/export, real libraries | 10–20 | | M3 Components & styles depth | ~75% | extended collections, variables import/export, real libraries | 10–20 |
| M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 | | M4 Vector & text depth | ~75% (live booleans, arc fields, shape builder on strokes, one-sided widths landed 2026-10-11) | spell check on Linux and the web, Figma Draw (brushes, pressure, text on a path, patterns), boolean hit testing on the result | 18–32 |
| M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 | | M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 |
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 | | M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
@ -71,6 +71,7 @@ blockers.
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |

View File

@ -1,6 +1,6 @@
# FigCraft parity with Figma # FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%; 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-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; the standalone server with share links to edit or view, wording only; live boolean editing and the vector tail: feature breadth ~79%; ready ~47%; 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) 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 summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
| Number | Value | Kind | | Number | Value | Kind |
|---|---|---| |---|---|---|
| Menu/command breadth | **98.5%** (200 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, re-checked from source on 2026-10-10) | | Menu/command breadth | **98.5%** (201 / 204 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, re-checked from source on 2026-10-10) |
| Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below | | Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below |
| Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) | | Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) |
| Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) | | Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) |
@ -36,7 +36,7 @@ tool; presence was being graded as behaviour in places.
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---| |---|---|---|---|
| Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | | Full target (ready for real work) | ~47% | ~370–695 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
| Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (91–177), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Mainstream practitioner | ~43% | ~175–320 h | core editor depth incl. `.fig` import (88–173), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@ -103,7 +103,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 | | 7 | Effects | 4 | 100% | 72% | Glass and progressive blur tuned side by side | 3–6 |
| 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 | | 8 | Frames, groups, sections, masks | 5 | 95% | 75% | Visual QA with real files | 2–4 |
| 9 | Auto layout & constraints | 9 | 95% | 68% | Grid `fr` rows around fixed children; behaviour against real Figma files ([auto-layout-parity](auto-layout-parity.md)) | 6–12 | | 9 | Auto layout & constraints | 9 | 95% | 68% | Grid `fr` rows around fixed children; behaviour against real Figma files ([auto-layout-parity](auto-layout-parity.md)) | 6–12 |
| 10 | Text | 8 | 85% | 64% | Spell check, text on a path, CJK text QA, real-file text metrics | 8–14 | | 10 | Text | 8 | 88% | 66% | Spell check on Linux, FreeBSD and the web and in other languages (Windows and macOS use the system's checker), text on a path, CJK text QA, real-file text metrics | 5–10 |
| 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 | | 11 | Components & variants | 9 | 95% | 68% | Real (not synthetic) libraries, `.fig` libraries, component playground ([components-parity](components-parity.md)) | 6–12 |
| 12 | Styles & variables | 5 | 85% | 70% | Extended collections, variables import/export, gradient stops and variant properties bound | 4–8 | | 12 | Styles & variables | 5 | 85% | 70% | Extended collections, variables import/export, gradient stops and variant properties bound | 4–8 |
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 | | 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
@ -115,14 +115,14 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 | | 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 |
| 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 | | 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 |
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **211–420** | | | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **208–416** |
| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 91–177 | | | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 88–173 |
## By dimension (ready for real work) ## By dimension (ready for real work)
| Dimension | Weight | Ready | Hours | Evidence | Doc | | Dimension | Weight | Ready | Hours | Evidence | Doc |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| Features (depth) | 35 | 57% | 71–135 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | | Features (depth) | 35 | 57% | 68–131 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) | | File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | | Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
@ -153,7 +153,7 @@ graphic or a simple mockup. Same method, with the core features a casual user to
| Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool | | Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool |
| Frames with device presets | 8 | 80% | | | Frames with device presets | 8 | 80% | |
| Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | | | Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | |
| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check | | Text: type, font, size, weight, colour, alignment | 12 | 70% | spell check on Windows and macOS only |
| Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images | | Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images |
| Layers panel: rename, hide, lock, reorder | 7 | 80% | | | Layers panel: rename, hide, lock, reorder | 7 | 80% | |
| Basic auto layout (add, gap, padding) | 6 | 70% | | | Basic auto layout (add, gap, padding) | 6 | 70% | |
@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | | 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h |
| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |

View File

@ -1,6 +1,6 @@
# UI and interaction parity # UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (spell check while editing text on Windows and macOS; menu breadth 201/204; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@ -57,7 +57,7 @@ interaction feel not yet compared.
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | |
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| Boolean groups: double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | hit-tested by the operands, not the result | | Boolean groups: double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | hit-tested by the operands, not the result |
| Text: point, auto height, fixed; caret, words, lines, IME | done | no spell check | | Text: point, auto height, fixed; caret, words, lines, IME; spell check | done | spell check uses the system's checker on Windows and macOS only, in its language |
| Eyedropper (I) | done | | | Eyedropper (I) | done | |
| Comment (C), Shift+C to show/hide | done | local only | | Comment (C), Shift+C to show/hide | done | local only |
| Brushes, text on a path (Figma Draw) | missing | | | Brushes, text on a path (Figma Draw) | missing | |
@ -91,5 +91,6 @@ interaction feel not yet compared.
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-10-11 | minor | Spell check while editing text: misspelled words underlined, suggestions, Ignore and Add to dictionary in the context menu, Text ▸ Spelling ▸ Check spelling (the system's checker on Windows and macOS, in the system's language; none on Linux, FreeBSD or the web yet). Text 85% → 88% breadth, 64% → 66% ready, 8–14 h → 5–10 h; menu breadth 200/203 → 201/204 (98.5%); gap 11 narrowed to the remaining platforms and languages (4–8 h → 2–5 h); weighted percentages unchanged, hour totals down 3–4 h (headline ranges unchanged within their rounding) |
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) | | 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code | | 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |