Skip to content

Spell check: engine, squiggles, suggestions menu + Android packaging & touch support#24

Merged
kevincarlson merged 12 commits into
mainfrom
claude/spell-check-rust-ixpgvn
Jun 28, 2026
Merged

Spell check: engine, squiggles, suggestions menu + Android packaging & touch support#24
kevincarlson merged 12 commits into
mainfrom
claude/spell-check-rust-ixpgvn

Conversation

@kevincarlson

Copy link
Copy Markdown
Member

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.
  • Dictionary catalog, bundling, and licensed downloads (with consent).
  • Spell check wired into the apps — visible squiggles in loki-text.
  • Suggestions panel and language picker.
  • Right-click spelling context menu: accurate hit-test, hover state, and a
    formatting-preserving replace (delete+insert that keeps the replaced
    word's character formatting), plus squiggle refresh on dictionary/language
    changes.
  • (The TEMP(probe) position:absolute experiment is reverted within the branch.)

Android build & deploy tooling

  • loki-text/Cargo.toml: default to a universal build_targets
    (aarch64 + x86_64) so a plain build runs on phones/tablets and
    Chromebooks/ARC/x86_64.
  • scripts/build-android.sh: --abi {auto|arm64|x64|all} (auto-detects the
    connected device's ABI), a --gpu flag (RUSTFLAGS='--cfg android_gpu',
    Vello GPU renderer), and a robust --install (uninstall+retry on
    debug-keystore signature mismatch). Mirrored as -Abi in build-android.ps1.
  • Play Store AAB via a committed Gradle wrapper under android/ +
    scripts/build-aab.sh. It reproduces the manifest config proven to work via
    cargo-apk (NativeActivity lib_name=loki_text, the FilePickerActivity SAF
    trampoline, minSdk 26), packages the cargo-built .so per ABI, and runs
    gradlew bundleRelease. (dx bundle aab is unusable here: it generates its
    own webview/wry MainActivity, omits FilePickerActivity, lowers minSdk to
    24, 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 menu path. Timer-driven (works for a perfectly still
finger), with correct hover/hit-test and a follow-up poll so the events reach the
Dioxus handlers.

Testing

  • cargo check / targeted cargo clippy clean for the changed crates.
  • Built Release + GPU for x86_64 and deployed to a ChromeOS/ARC device;
    confirmed GPU rendering and clean launch.
  • Verified on-device that a stationary press-hold fires the long-press timer and
    reaches on_tile_context at a real document position.

🤖 Generated with Claude Code

claude and others added 12 commits June 27, 2026 05:23
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>
@kevincarlson kevincarlson merged commit c701333 into main Jun 28, 2026
2 checks passed
@kevincarlson kevincarlson self-assigned this Jun 28, 2026
@AppThere AppThere locked as resolved and limited conversation to collaborators Jun 28, 2026
@kevincarlson kevincarlson deleted the claude/spell-check-rust-ixpgvn branch June 28, 2026 15:32
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants