From 5bebfa589d56bb6deb656f674ae73c54ca501db6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 11 Oct 2026 10:20:35 +0700 Subject: [PATCH] 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. --- Cargo.lock | 88 ++++++++++- ROADMAP.md | 11 +- apps/figcraft/Cargo.toml | 4 + apps/figcraft/src/main.rs | 6 + apps/figcraft/src/services.rs | 16 ++ crates/engine/src/catalog.rs | 2 + crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/lib.rs | 12 ++ crates/engine/src/spelling.rs | 222 ++++++++++++++++++++++++++++ crates/engine/src/spelling_tests.rs | 92 ++++++++++++ crates/ui-egui/src/canvas.rs | 79 ++++++++-- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menus.rs | 30 +++- docs/gaps.md | 19 ++- docs/mcp.md | 11 ++ docs/parity-checklist.md | 4 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 17 ++- docs/ui-parity.md | 5 +- 19 files changed, 582 insertions(+), 46 deletions(-) create mode 100644 crates/engine/src/spelling.rs create mode 100644 crates/engine/src/spelling_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 0b7e674e..f6e2177d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1408,6 +1408,7 @@ dependencies = [ "figcraft-ui-egui", "image", "log", + "os-spellcheck", "rfd", "serde_json", "webbrowser", @@ -2794,8 +2795,8 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.3", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", ] @@ -2808,10 +2809,17 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", + "libc", "objc2 0.6.5", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", "objc2-core-foundation", "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -2827,6 +2835,17 @@ dependencies = [ "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]] name = "objc2-contacts" version = "0.2.2" @@ -2850,6 +2869,17 @@ dependencies = [ "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]] name = "objc2-core-foundation" version = "0.3.2" @@ -2886,6 +2916,16 @@ dependencies = [ "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]] name = "objc2-core-location" version = "0.2.2" @@ -2898,6 +2938,31 @@ dependencies = [ "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]] name = "objc2-encode" version = "4.1.0" @@ -2925,6 +2990,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", + "libc", "objc2 0.6.5", "objc2-core-foundation", ] @@ -3022,9 +3088,9 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.3", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", @@ -3111,6 +3177,18 @@ dependencies = [ "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]] name = "parking" version = "2.2.1" diff --git a/ROADMAP.md b/ROADMAP.md index d645e0d1..dcd6572a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **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) · **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 @@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | | 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 | **~78%** (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) | | 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 | |---|---|---|---| | Full target (ready for real work) | ~47% | ~375–705 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% | ~180–330 h | core editor depth incl. `.fig` import (98–188), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Mainstream practitioner | ~43% | ~180–330 h | core editor depth incl. `.fig` import (95–184), 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) | 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 | |---|---|---|---| -| Features (depth) | 57% (core editor 67%) | 78–146 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 57% (core editor 67%) | 75–142 | [target-app-parity](docs/target-app-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) | | Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; 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) | | 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) | -| 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) | | Styles & variables | 85% | 70% | 4–8 | [components-parity](docs/components-parity.md) | | Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) | @@ -152,6 +152,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 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 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 | 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-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index f96097ed..bab2c389 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -29,5 +29,9 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col [target.'cfg(windows)'.dependencies] 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] winresource = "0.1" diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 4ac43de0..d26da3dd 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -120,6 +120,12 @@ fn main() -> eframe::Result { // default, never a build from source unless asked). session.live.links = figcraft_collab_net::links::system(); 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() { let _ = session.execute("file.new", &json!({})); } diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index e7af76a3..36f3037b 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -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); + +#[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 { + self.0.get_or_insert_with(os_spellcheck::SpellChecker::new).suggestions(word) + } +} + /// The window icon from the bundled app icon (if present). pub fn window_icon() -> Option { let bytes = include_bytes!("../../../assets/app-icon/figcraft-256.png"); diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index f7d9c679..ccbe123b 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -197,6 +197,8 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + m!("Text", SEP, ""), + m!("Text/Spelling", "Check spelling", "text.spelling"), // Arrange m!("Arrange", "Align left", "align.left"), m!("Arrange", "Align horizontal centers", "align.hcenter"), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8c2029ab..ddbf961b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -97,6 +97,7 @@ fn registry() -> &'static Registry { list.extend(crate::plugins::specs()); list.extend(crate::live::specs()); list.extend(crate::invite_links::specs()); + list.extend(crate::spelling::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/lib.rs b/crates/engine/src/lib.rs index 02eff05f..466efe89 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -33,6 +33,7 @@ pub mod plugins; pub mod proto_run; pub mod shapebuilder; pub mod snap; +pub mod spelling; pub mod synthetic; pub mod synthetic_ds; pub mod textedit; @@ -455,6 +456,10 @@ pub struct Prefs { /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, + /// Underline misspelled words while editing text (`text.spelling`). + pub spell_check: bool, + /// Words never flagged as misspelled (`spelling.addWord`), lowercased. + pub dictionary: Vec, /// Installed plugins (Plugins menu), in menu order. #[serde(serialize_with = "plugins::persisted")] pub plugins: Vec, @@ -474,6 +479,8 @@ impl Default for Prefs { user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, invite_links: None, + spell_check: true, + dictionary: Vec::new(), plugins: Vec::new(), } } @@ -525,6 +532,8 @@ pub struct Session { pub plugins: plugins::Runtime, /// The live session (collaboration): status, peers, presence. pub live: live::Live, + /// Spell check while editing text. + pub spelling: spelling::Spelling, } impl Default for Session { @@ -557,6 +566,7 @@ impl Session { libraries: library::Cache::default(), plugins: plugins::Runtime::default(), live: live::Live::default(), + spelling: spelling::Spelling::default(), } } @@ -1219,6 +1229,8 @@ mod shapebuilder_tests; #[cfg(test)] mod slot_tests; #[cfg(test)] +mod spelling_tests; +#[cfg(test)] mod style_tests; #[cfg(test)] mod svg_import_tests; diff --git a/crates/engine/src/spelling.rs b/crates/engine/src/spelling.rs new file mode 100644 index 00000000..eb5122c4 --- /dev/null +++ b/crates/engine/src/spelling.rs @@ -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; +} + +/// 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>, + /// Word → misspelled, as the checker answered. + known: HashMap, + /// Lowercased words ignored for this run of the app. + ignored: HashSet, + /// The flagged words of text layer `NodeId` in that document. + flagged: Option<(Arc, 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 = 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> { + let te = self.ds()?.text_edit.clone().ok_or_else(|| EngineError::Other(format!("{cmd}: not editing text")))?; + let chars: Vec = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + let w = word_param("spelling.ignore", p)?; + s.spelling.ignored.insert(w); + s.spelling.flagged = None; + ok() +} + +pub fn specs() -> Vec { + 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), + ] +} diff --git a/crates/engine/src/spelling_tests.rs b/crates/engine/src/spelling_tests.rs new file mode 100644 index 00000000..a7142625 --- /dev/null +++ b/crates/engine/src/spelling_tests.rs @@ -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>); + +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 { + vec![format!("{word}!"), "world".into()] + } +} + +/// Editing a text layer "Hello wrold, don't Helo 42" with the fake checker installed. +fn editing() -> (Session, Arc>) { + 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()); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 77d1b7ae..a3acf941 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -141,6 +141,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { input(app, ui, &resp); crate::live::track_pointer(app, &ctx); app.sync_preedit(); + app.session.sync_spelling(); request_render(app, worker, ppp); // 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. @@ -453,6 +454,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { /// open the context menu there. pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { 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() { 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) { @@ -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())); } +/// 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)> { + 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"))] fn dropped_bytes(f: &std::sync::Arc) -> Option> { 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() } + /// 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 /// of default height at its origin). 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) { 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 && let Some(text) = TextOnScreen::of(edoc, te.id) { - let layout = &text.layout; if let Some(q) = node_quad(app, edoc, te.id) { 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 { - for r in layout.selection_rects(p.range.0, p.range.1) { - // Underline just below the baseline, across the composed chars only. - let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue }; - 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 >= 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)); + for seg in text.underline(app, p.range.0, p.range.1) { + painter.line_segment(seg, stroke(t.text, 1.0)); + } + } else { + // Misspelled words, except the one being typed. + for &(a, b) in app.session.misspelled().iter().filter(|(a, b)| !(*a..=*b).contains(&te.caret)) { + for seg in text.underline(app, a, b) { + squiggle(painter, seg, t.danger); + } } } let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60ed3cea..3ebf4b47 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -226,6 +226,9 @@ pub struct UiState { pub preview: Option, pub picker: Option, pub context_menu: Option<(egui::Pos2, Vec)>, + /// 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)>, /// 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). pub layer_search: bool, @@ -317,6 +320,7 @@ impl Default for UiState { preview: None, picker: None, context_menu: None, + context_spelling: None, layer_search: false, layer_filter: String::new(), layer_anchor: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f68e66b5..217680c0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -181,7 +181,8 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { } last_sep = false; 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); } } else if let Some(rest) = m.menu.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) { @@ -361,6 +362,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { 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| { menu_frame(ui, &t, |ui| { + if spelling_entries(app, ui, pos, &t) { + close = true; + } if select_layer_menu(app, ui, pos, &t) { close = true; } @@ -459,6 +463,30 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { 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 { Id::new("ctx_select_layer_open") } diff --git a/docs/gaps.md b/docs/gaps.md index b87062d7..23ffb16c 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-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **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) · **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 @@ -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) | | 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) | -| 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) | | 13 | Live boolean editing and the vector tail | Feature | 6–10 | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections, import/export | Feature | 4–8 | [components-parity](components-parity.md) | @@ -148,11 +148,15 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Impact:** design-system teams. Builds on multiplayer's server. - **Estimate:** 10–20 h after gap 2. -### 11. Spell check -- **Missing:** spelling underline and suggestions while editing text. -- **Evidence:** no spell-check code in the repo. -- **Estimate:** 4–8 h with a permissively licensed dictionary and checker (provenance per - `ATTRIBUTION.md`). +### 11. Spell check on Linux, FreeBSD and the web; one language +- **Have:** misspelled words in the text being edited get a wavy underline; right-click one for + suggestions, Ignore (until the app quits) and Add to dictionary (saved in preferences); Text ▸ + Spelling ▸ Check spelling, on by default (`crates/engine/src/spelling.rs`). The desktop app uses + 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 - **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video @@ -218,6 +222,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 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 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 | 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-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | diff --git a/docs/mcp.md b/docs/mcp.md index fb91b222..1b49a3a9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -435,6 +435,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 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 Comment threads live in the file, pinned to a page point or to a layer (`anchor`: a node id, or diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 3f44670a..3b4ced8e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,9 +2,9 @@ > **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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 13d35a63..8a9eba16 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-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **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) · **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 @@ -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 | | 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 | -| M4 Vector & text depth | ~70% | live booleans, spell check, Figma Draw (brushes, pressure, text on a path, patterns) | 24–40 | +| M4 Vector & text depth | ~70% | live booleans, spell check on Linux and the web, Figma Draw (brushes, pressure, text on a path, patterns) | 24–40 | | 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 | | 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 | |---|---|---| +| 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 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 | 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-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index befca116..b2df74fb 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-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged; last major: the 2026-10-10 full re-measure) · **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) · **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 @@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | 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) | **~78%** (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) | | 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 | |---|---|---|---| | Full target (ready for real work) | ~47% | ~375–705 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% | ~180–330 h | core editor depth incl. `.fig` import (98–188), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | +| Mainstream practitioner | ~43% | ~180–330 h | core editor depth incl. `.fig` import (95–184), 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) | 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 | | 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 | -| 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 | | 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 | @@ -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 | | 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 | -| | **Weighted (÷113)** | 113 | **~78%** | **~57%** | | **218–431** | -| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 98–188 | +| | **Weighted (÷113)** | 113 | **~78%** | **~57%** | | **215–427** | +| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 95–184 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 57% | 78–146 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file | +| Features (depth) | 35 | 57% | 75–142 (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) | | 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, 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 | | Frames with device presets | 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 | | Layers panel: rename, hide, lock, reorder | 7 | 80% | | | Basic auto layout (add, gap, padding) | 6 | 70% | | @@ -572,6 +572,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | 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 | 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-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | | 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (~400–750 h), mainstream (~180–330 h) and essentials (~85–160 h). Full number confirmed as an additive weighted sum; unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a4d03cf6..4cd049e4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; collected from the ROADMAP, the keymap and the canvas code) · **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) · **Target:** Figma Design UI3 (desktop app 126.10.7) 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: @@ -56,7 +56,7 @@ interaction feel not yet compared. | Pen: vector networks, branch, close, bend; pencil | done | no pen pressure | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | live boolean editing missing | | Shape builder (Shift+M), variable width (Shift+W) | done | | -| 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 | | | Comment (C), Shift+C to show/hide | done | local only | | Brushes, text on a path (Figma Draw) | missing | | @@ -90,4 +90,5 @@ interaction feel not yet compared. | 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-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |