Spell check: engine, squiggles, suggestions menu + Android packaging & touch support#24
Merged
Merged
Conversation
Introduce the `loki-spell` crate, the foundation for spell checking in
the Loki suite. It wraps the pure-Rust `spellbook` engine (a Rust port
of Nuspell) so the workspace keeps `#![forbid(unsafe_code)]` — no FFI to
the C libhunspell — and reads standard Hunspell `.aff`/`.dic`
dictionaries, which ship with LibreOffice/Mozilla for every locale.
Engine (`loki-spell`):
- `SpellChecker::new(aff, dic)` loads a dictionary; `is_correct`,
`suggest` (ranked corrections), `add_word` (in-memory personal entry),
and `ignore_word` (case-insensitive session ignore list).
- `tokenizer` splits text into checkable words with byte ranges (Unicode
alphanumeric runs, internal apostrophe/hyphen connectors, digit tokens
skipped); `check_text` returns a `Misspelling { word, range }` per
flagged word for mapping onto the document.
- Typed `SpellError` via thiserror; 17 unit tests + doctest, all green.
Render primitive:
- Add `DecorationKind::Spelling` to `loki-layout` and paint it in
`loki-vello` as a wavy underline whose amplitude tracks line thickness
(scales with zoom). It is emitted from spell results, not character
styling, so it never round-trips to a document format.
Docs: register the feature in docs/fidelity-status.md §11 (engine and
squiggle primitive done; dictionary bundling, editor wiring, and personal
-dictionary persistence marked pending) and note the crate in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
Add the dictionary loading/bundling layer on top of the spell-check engine, designed for a worldwide release: a bundled permissive default, locale-driven resolution, and on-demand downloads of other languages with a license-consent gate and integrity verification. Bundled default: - Embed a permissive `en` dictionary (SCOWL-derived, (MIT AND BSD)) under assets/dictionaries/en/ with its license text. `SpellChecker::bundled()` loads it — works offline / on first run and is the ultimate fallback. Catalog (`catalog.rs` + assets/catalog.json): - Data-driven manifest of available dictionaries: BCP-47 tag, English and native names, SPDX license, `LicenseClass`, bundled flag, and a download source (URL + SHA-256 + byte size) pinned to an immutable wooorm/dictionaries commit. Seeded with en (bundled) + fr/es/de (downloadable). Parsing rejects any bundled non-permissive entry. Locale resolution (`locale.rs`): - BCP-47 normalization + most-specific-first fallback chain so a host locale like `en-US` resolves to `en`. License policy (`license.rs`): - `LicenseClass` (Permissive / LesserCopyleft / Copyleft): only permissive may be bundled; copyleft/lesser-copyleft are downloadable but gated behind an explicit `Consent::Granted` (else `SpellError::ConsentRequired`), preserving the user's right to obtain and redistribute GPL/LGPL/MPL dictionaries. Store + download (`store.rs`, `fetch.rs`): - `DictionaryStore` caches installed dictionaries on disk (one dir per tag + meta.json recording the license for attribution). `install_dictionary` runs the consent gate, downloads via a caller-supplied `DictionaryFetcher` (keeps loki-spell HTTP-free — the app owns the network), verifies size + SHA-256, then installs; corrupt content is rejected, never written. 39 unit/integration tests (incl. a real load of the bundled en dictionary). Docs: update fidelity-status §11; add assets/dictionaries/README.md (provenance + bundling policy). All under the 300-line ceiling; fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
Integrate the spell-check engine end-to-end across the suite: a shared
runtime service in every app, and a layout-level squiggle path that makes
misspelled words render in the word processor.
Layout engine (loki-layout):
- `LayoutOptions::spell` carries a `SpellState { checker, generation }`.
When present, `layout_paragraph` emits `DecorationKind::Spelling`
decorations for misspelled words via the same Parley selection-geometry
pass as the highlight underlay (so squiggles track wrapping and
coalesced runs). The checker `generation` folds into the paragraph
cache key so squiggles invalidate when the dictionary changes. Defaults
to `None` — zero overhead and unchanged behaviour when unset.
Shared service (loki_app_shell::spell):
- `SpellService` boots on the bundled `en` dictionary; suite-wide
dictionary cache dir (dirs::data_dir, Android-aware), OS locale
detection (sys-locale), and a blocking-reqwest (rustls) `ReqwestFetcher`.
`snapshot()` hands the active checker to layout; `activate_language` /
`install_and_activate` switch or download languages (consent + SHA-256
enforced by loki-spell). Provided into all three apps' context.
loki-text (visible end-to-end):
- App root boots the service and installs the active checker into the
renderer's ambient `loki_renderer::spell` state, which both the paint
layout (doc_page_source) and the editor's hit-test layout read into
`LayoutOptions::spell`. Squiggles work offline on the bundled `en`.
loki-spreadsheet / loki-presentation:
- Service provided into context (is_correct/suggest/check available).
These apps don't render via loki-layout, so visible in-cell/in-shape
squiggles remain a follow-up in their own renderers.
Tests: layout squiggle emission + disabled-path; service bootstrap,
snapshot/enabled, catalog resolution, activation. Also fixed two
pre-existing stale test literals surfaced by the new field
(render_layout_tests StyleSpan, recent_documents field-reassign).
Docs: fidelity-status §11 and CLAUDE.md updated. fmt + clippy
-D warnings clean on all changed crates; full workspace check passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
Add the two interactive spell-check features to the word processor, both as docked panels above the ribbon (Blitz has no position:absolute, so a floating context menu is not possible). Suggestions panel (editor_spell_panel + editor_spell): - Right-click a word (oncontextmenu on the canvas) hit-tests the position, resolves the word, and opens a panel with ranked suggestions. Clicking a suggestion replaces the word as one undoable edit (delete_text + insert_text). Add-to-Dictionary and Ignore apply to the shared checker and re-check the document. Language picker (editor_language_panel): - Lists the catalog: the active language is marked, offline languages activate immediately, others download on demand. The dictionary's SPDX license is shown by the Download action (the click is consent; loki-spell still enforces the gate + SHA-256). Downloads run on a worker thread, then refresh the ambient checker and force a relayout. Engine support: - SpellChecker overrides (ignore list + personal words) move behind a RwLock so add_word/ignore_word take &self — the change is visible through the Arc the layout engine holds. SpellService.add_word/ignore_word bump the generation so the paragraph cache invalidates and squiggles refresh. Removed the now-unused WordAdd error variant. Reactivity: - Dictionary/language changes (no document edit) refresh the renderer's ambient spell state and run a full relayout (force_full_relayout), then bump cursor_state.document_generation so the canvas repaints. Housekeeping: extracted the save-status banner into editor_save_banner to offset the wiring added to the over-ceiling editor_inner. New strings in editor.ftl. All new files under the 300-line ceiling; fmt + clippy -D warnings clean; full workspace check passes. NOTE: the panels are verified to compile and pass logic/unit tests, but their on-screen rendering and oncontextmenu/prevent_default behaviour are not verifiable headless and need on-device confirmation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
The suggestions panel never opened because it was wired to `oncontextmenu`, which the patched Blitz shell does not deliver. The input-event audit and the blitz-dom event driver confirm that `mousedown` IS forwarded for every button with the button preserved (`trigger_button()` → `Secondary` for right-click), so move the trigger there. - editor_canvas: drop the dead `oncontextmenu` handler; in `onmousedown`, when `trigger_button() == Some(MouseButton::Secondary)`, resolve the word under the pointer and open the panel (extracted to `open_spell_panel_at`). Right-click now also selects the whole word so the user sees what the suggestions apply to; a left click is unchanged (records drag origin). The panel is still docked above the ribbon (Blitz has no position:absolute). A true cursor-anchored floating menu would need the shell tooltip-overlay technique (patches/blitz-shell/src/tooltip.rs) extended with click hit-testing — noted as deferred in fidelity-status §11. fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
TEMPORARY verification probe — to be reverted once the result is recorded. Per the investigation, Blitz uses Stylo + stylo_taffy + Taffy 0.9, which should support block-level position:absolute; the "unsupported" claim in the docs is an unverified assertion. This probe settles it empirically. Mounts a position:relative container at the top of the app window with two position:absolute children (top/left- and bottom/right-anchored) plus an in-flow label. If the boxes land in the container's corners and the label stays put, absolute positioning works and the floating context menu needs no Blitz layout patch. Not committed as a feature — `position_absolute_probe()` and its call in `App` are to be removed after verification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
The position:absolute probe confirmed the current Blitz stack (Stylo + stylo_taffy 0.2 + Taffy 0.9) honours block-level absolute positioning, so the "unsupported" claim was stale. Convert the docked suggestions panel into a real cursor-anchored floating context menu and drop the probe. - editor_spell_panel: render as a `position: absolute` menu at the right-click coordinates, inside the `position: relative` editor root, with a transparent full-area backdrop that dismisses on outside-click. Suggestions are now a vertical menu list; horizontal position clamps to the viewport. The docked layout is gone. - editor_spell: SpellMenu carries the click anchor (anchor_x/anchor_y); editor_canvas fills them from the right-click position. - editor_inner: editor root gains `position: relative`; passes window width to the menu for clamping. - app: remove the temporary position:absolute probe. Docs: CLAUDE.md now lists block-level position:absolute as confirmed working (with the verification details and the inline/containing-block/ overflow caveats); position:fixed noted as collapsing to absolute. fidelity-status §11 updated. fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
Two fixes for the floating spelling menu. Hit-test (was resolving the last word on the line): the right-click went through the window-coordinate path (`hit_test_document`), which derives the page-centring offset from a window width that is never set — so every click landed past the line end. Route right-click through the page tile instead, which already uses accurate `element_coordinates` for left-click: - loki-renderer: add `TileContext` + `DocumentView::on_tile_context`; the page tile fires it on the secondary mouse button with tile-local layout points (for `hit_test_page`) plus client coords (to anchor the menu). - editor_canvas: `open_spell_panel_at` now resolves via `hit_test_page` (same accurate path as left-click); the canvas `onmousedown` ignores the secondary button (the tile handles it). Drops the `hit_test_document` dependency and its window-width guesswork. Hover (menu rows had no hover state): Blitz has no CSS `:hover`, so the hovered row is tracked with `onmouseenter`/`onmouseleave` + a `Signal<Option<String>>` key and applied as an inline background — the existing codebase pattern (template_gallery / home_tab). Docs: fidelity-status §11 updated. fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
…ggle refresh Address three issues found testing the spell-check context menu in Loki Text: - Replacing a word in a multi-run heading turned the whole run uniform (e.g. a black word right after a red run went red). Every Loro char mark is `expand: After`, so a delete+insert at a run boundary lets the preceding run's marks swallow the inserted text. New `replace_text` mutation captures the replaced range's marks and re-applies the full `CHAR_MARK_KEYS` set to the inserted range (clearing any leaked mark to Null), so the replacement matches the text it replaced and neighbours are untouched. `CHAR_MARK_KEYS` in `loro_schema` is now the single source of truth shared with the mark-registration path. Covered by two tests. - Menu row hover did nothing: the rows used `onmouseenter`/`onmouseleave`, which the patched Blitz shell never dispatches. Switched to `onmousemove` per-row (sets the hovered key) plus a backdrop `onmousemove` that clears it, guarded with `peek()` to avoid redundant re-renders. - Add-to-Dictionary / Ignore / language changes did not refresh squiggles: the relayout reused the same document `Arc`, which the paint layout compares by pointer and so skipped recomputation. `force_full_relayout` now republishes a fresh `Arc` of the (unchanged) document so squiggles re-check under the new checker generation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SinN8pCKB9ChdJDMZdt7QT
Local deploy (cargo-apk pipeline):
- Cargo.toml: default build_targets to a universal set (aarch64 + x86_64) so a
plain build runs on both phones/tablets and Chromebooks/ARC/x86_64.
- build-android.sh: add --abi {auto|arm64|x64|all} (auto-detects the connected
device's ABI and builds only that target), a --gpu flag that sets
RUSTFLAGS='--cfg android_gpu' (Vello GPU renderer), and make --install robust
against debug-keystore signature clashes (uninstall + retry).
- build-android.ps1: mirror the -Abi option for Windows parity.
Play Store (AAB):
- android/: committed Gradle wrapper that produces a proper Android App Bundle.
It reproduces the manifest config proven to work via cargo-apk -- NativeActivity
with lib_name=loki_text, the FilePickerActivity SAF trampoline, hasCode=true,
and minSdk 26. `dx bundle aab` is NOT usable here: it generates its own
webview/wry MainActivity, omits FilePickerActivity, lowers minSdk to 24, and
crashes at launch (never calls blitz_shell::set_android_app). Minification is
kept off so R8 cannot strip/rename FilePickerActivity (resolved by name over JNI).
- build-aab.sh: build the cdylib per ABI, stage .so + FilePickerActivity.java into
the wrapper, and run `gradlew bundleRelease`. Generated artifacts (jniLibs, the
staged Java copy, build/) are gitignored.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On Dioxus-native/Blitz, touch input reaches the app only as synthesised mouse events — blitz-dom dispatches no DOM touch events, so the editor's ontouch* handlers never fire, and the secondary (right-click) button that opens the spelling suggestions menu was never produced from touch. Touch users (including Chromebooks) therefore had no way to reach the menu. Add a timer-armed long-press in the blitz-shell window handler: on touch-start, arm a 500ms wake-up (mirroring the existing tooltip timer); if the contact is still held in place when it fires, synthesise a secondary mouse click at the press point. This flows through the existing onmousedown(Secondary) -> on_tile_context -> spell menu path unchanged, and works in both paginated and reflow views. It is timer-driven so it fires even for a perfectly still finger (a touchmove-based approach was unreliable — a stationary contact streams no move events). The synthesis re-establishes hover at the press point for correct hit-testing and queues a follow-up Poll so the dispatched DOM events reach the Dioxus handlers; primary-button bookkeeping stays balanced so a subsequent tap still works. Verified on-device (x86_64 ChromeOS/ARC): a stationary press-hold fires the long-press timer and the synthesised secondary reaches on_tile_context at a real document position. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`StyleSpan` gained `scale` and `baseline_shift` fields, but five test-only initializers in loki-text were not updated, so `cargo test` failed to compile the lib-test target (non-test builds were unaffected, which is why it slipped through). Add `scale: None` / `baseline_shift: None` (no-effect defaults) to the fixtures in hit_test.rs, navigation.rs, and reflow_nav.rs. cargo test -p loki-text -p loki-spell --all-features now passes (loki-text: 46, loki-spell: 40). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds spell-checking to the Loki suite end to end — engine, dictionary
management, in-document squiggles, and a suggestions/context menu — and brings
the Android build, deploy, and packaging tooling needed to ship and test it,
including touch support for the new menu.
Spell-check feature
loki-spell— Hunspell-compatible spell-check engine.loki-text.formatting-preserving replace (delete+insert that keeps the replaced
word's character formatting), plus squiggle refresh on dictionary/language
changes.
TEMP(probe)position:absolute experiment is reverted within the branch.)Android build & deploy tooling
loki-text/Cargo.toml: default to a universalbuild_targets(
aarch64+x86_64) so a plain build runs on phones/tablets andChromebooks/ARC/x86_64.
scripts/build-android.sh:--abi {auto|arm64|x64|all}(auto-detects theconnected device's ABI), a
--gpuflag (RUSTFLAGS='--cfg android_gpu',Vello GPU renderer), and a robust
--install(uninstall+retry ondebug-keystore signature mismatch). Mirrored as
-Abiinbuild-android.ps1.android/+scripts/build-aab.sh. It reproduces the manifest config proven to work viacargo-apk (NativeActivity
lib_name=loki_text, theFilePickerActivitySAFtrampoline,
minSdk 26), packages the cargo-built.soper ABI, and runsgradlew bundleRelease. (dx bundle aabis unusable here: it generates itsown webview/wry MainActivity, omits
FilePickerActivity, lowersminSdkto24, and crashes at launch — documented in the script.)
Android touch: long-press → spelling menu
On Dioxus-native/Blitz, touch reaches the app only as synthesised mouse
events (blitz-dom dispatches no DOM touch events), so the secondary (right-click)
button that opens the suggestions menu was never produced from touch — leaving
touch users (incl. Chromebooks) no way to reach it. A timer-armed long-press
in the blitz-shell window handler now synthesises a secondary click at the press
point when a contact is held in place, flowing through the existing
on_tile_context → spell menupath. Timer-driven (works for a perfectly stillfinger), with correct hover/hit-test and a follow-up poll so the events reach the
Dioxus handlers.
Testing
cargo check/ targetedcargo clippyclean for the changed crates.confirmed GPU rendering and clean launch.
reaches
on_tile_contextat a real document position.🤖 Generated with Claude Code