Compare commits
2 Commits
main
...
feat/spell
| Author | SHA1 | Date |
|---|---|---|
|
|
e1cd613b1d | |
|
|
5bebfa589d |
|
|
@ -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"
|
||||||
|
|
|
||||||
11
ROADMAP.md
11
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)
|
**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 |
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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!({}));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
docs/gaps.md
19
docs/gaps.md
|
|
@ -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 |
|
||||||
|
|
|
||||||
11
docs/mcp.md
11
docs/mcp.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue