From 20b05a6e76c0aca9030f8c97d41eac338b76953a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:27:11 +0000 Subject: [PATCH 01/27] docs: add remediation plan for the 2026-07-04 deferred-features audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the audit's verified §2–§7 deferrals into a prioritized, phased plan: verification of unverified findings (F1–F7), CRDT round-trip integrity, the loki-file-access patch upstreaming, Spec 02 conformance foundation, editor completion (Spec 03–05), fidelity/perf/quality backlogs, plus an upstream-gated watch list and explicit out-of-scope boundary. The audit's §8 doc-hygiene actions were already applied and are excluded. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-plan-2026-07-04.md | 228 ++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/deferred-features-plan-2026-07-04.md diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md new file mode 100644 index 00000000..e0078037 --- /dev/null +++ b/docs/deferred-features-plan-2026-07-04.md @@ -0,0 +1,228 @@ + + +# Deferred-Features Remediation Plan (2026-07-04) + +| | | +|---|---| +| **Status** | Plan — no code changes in this document's pass. | +| **Source** | [`docs/deferred-features-audit-2026-07-04.md`](deferred-features-audit-2026-07-04.md) — every task below cites its audit section. | +| **Already done** | The audit's §8 documentation-hygiene actions (S-1…S-9 stale-doc fixes) were applied on 2026-07-04 and are **not** in this plan. Verified: no stale `TODO(editing)` / `position: absolute` COMPAT notes remain in code, and the `CLAUDE.md` rows are reconciled. | +| **What this plan covers** | The genuine, correctly-documented deferrals in audit §2–§7, sorted into: work we can and should do (Phases 0–7), upstream-gated items we can only watch (§ Watch list), and deliberate post-MVP scope we will not schedule (§ Out of scope). | + +## How to read this plan + +- **Priority**: P0 = correctness/data-integrity or unblocks other phases; P1 = committed spec work left unbuilt; P2 = quality/perf/fidelity backlog; P3 = polish. +- **Effort**: S ≤ 1 day, M ≤ 1 week, L > 1 week (single engineer, familiar with the crate). +- Phases are ordered by priority but are largely **independent workstreams** — they can proceed in parallel except where a dependency is called out. +- Every task keeps the repo conventions: root-cause fixes only, 300-line ceiling (ratcheted), `cargo fmt` + `clippy -D warnings`, and a `docs/fidelity-status.md` update for any layout/rendering/import-export change. + +--- + +## Phase 0 — Verification pass: re-drive the unverified findings (P0, S) + +The audit explicitly did **not** re-drive the app-layer findings F1–F7 from +`audit-2026-06-10` (presentation tab-switch edit loss, no-op delete/copy, dead +retier channels, no Save-As) — they are "likely-open pending a focused check" +(audit §5, closing note). Planning fixes against unverified findings risks the +exact stale-doc failure mode the audit was written to catch. + +| Task | Detail | Exit criterion | +|---|---|---| +| 0.1 | Re-drive F1–F7 against current `loki-text` / `loki-presentation` / `loki-spreadsheet` code; record each as open or resolved in a short addendum to the audit doc. | Each of F1–F7 has a verified status with `file:line` evidence. | +| 0.2 | Fold the confirmed-open subset into Phase 4 (they are all editor/app-shell UX items). | Phase 4 backlog updated. | + +--- + +## Phase 1 — CRDT round-trip integrity (P0, M) + +Data that survives import but is silently degraded by the Loro bridge is the +closest thing to data loss in the suite. These are all code-confirmed in audit +§6 and §2 (`loro-bridge` topic). + +| Task | Source | Detail | Effort | +|---|---|---|---| +| 1.1 | §6 | **`tab_stops` round-trip**: replace the Debug-string write (`loro_bridge/write.rs:187`) with a structured serialization (mirror the existing `props_read.rs` patterns) and add the missing reader. Add a round-trip test. | S–M | +| 1.2 | §6 | **Paragraph `background_color` round-trip**: same fix shape — structured write in `write.rs:190`, teach `props_read.rs:273` to decode it (today it calls `from_hex` on a Debug string and always fails). Round-trip test. | S | +| 1.3 | §6 | **CRDT bridge block stubs**: implement native bridge writes for `BulletList`/`OrderedList`/`Figure`/`BlockQuote`/`Div` (currently debug-log-only). The `Block::Table` write (`loro_bridge/write.rs:22`) is the worked example. Per-block round-trip tests. | M | +| 1.4 | §2 | **`loro-bridge` tail**: non-Rgb colors (Theme/Cmyk), comment/bookmark anchors, quote/span attrs (`loro_bridge/inlines.rs:123,220`, `opaque.rs`, `table.rs:25`). Structural-table CRDT semantics can stay deferred (design work) but must keep its TODO. | M | +| 1.5 | §2, §5 | **Loro oplog compaction** (`TODO(loro-compaction)`, memory Finding 6, Spec 06): compact at save/undo-horizon. `loki-bench/benches/leak_loro_history.rs` is the pre-built yardstick — use it as the acceptance test (history must stop growing linearly with edit count past the undo horizon). Server-side ADR-C013 `Compactor` already exists as prior art. | M | + +**Exit criteria**: `metadata_round_trip.rs`-style tests pass for 1.1–1.3; the +`leak_loro_history` bench shows bounded history; the two §6 tech-debt rows and +the bridge-stubs row in `CLAUDE.md` are updated to DONE. + +--- + +## Phase 2 — The one actionable dependency patch: `loki-file-access` (P0, S) + +Audit §3: of the 6 `[patch.crates-io]` entries, five are gated on upstream +Dioxus/Blitz releases (watch list), but **`loki-file-access` 0.1.2 is +same-team-owned** (`appthere/loki-file-access`) and its removal condition is +entirely in our hands. + +| Task | Detail | +|---|---| +| 2.1 | Push the local patch fixes to the `appthere/loki-file-access` repo (including the Gradle `FilePickerActivity.kt` piece noted in `docs/patches.md`). | +| 2.2 | Publish the new version, repoint the workspace dependency, delete the `[patch]` entry, and update `docs/patches.md` (patch count 6 → 5). | + +**Exit criterion**: `cargo build` with no `loki-file-access` patch and no +`Patch ... was not used` warnings. + +--- + +## Phase 3 — Conformance foundation: build the Spec 02 "resolved-as-decision" items (P1, L) + +Audit §1 note + §4: several Spec 02 items carry a "✅ Resolved" *decision* but +were never built — verified again 2026-07-04 (no Gelasio face in +`loki-fonts/fonts/`, no vendored schemas, zero goldens). This phase turns +decisions into artifacts, in dependency order. It also **unblocks Spec 06 +BM-3** (render-cost proxy) and the ACID headless-raster registry item. + +| Task | Spec item | Detail | Effort | +|---|---|---|---| +| 3.1 | B-10 | Bundle the Gelasio font face into `loki-fonts/fonts/` (license-check first, as with the existing Croscore/Crosextra faces). | S | +| 3.2 | B-6 | Vendor the ISO 29500 / ODF RELAX NG schemas into `appthere-conformance/schemas/` and wire schema validation. | M | +| 3.3 | B-1 | `vello_cpu` render path in the conformance crate (no GPU in CI). Prerequisite for 3.4–3.6 and Spec 06 BM-3. | M | +| 3.4 | B-5 | Pin a PDF→PNG rasterizer (version-locked) for golden comparison. | S | +| 3.5 | B-2, B-3, B-4 | Commit the first visual goldens; calibrate the SSIM threshold empirically (replace the uncalibrated 0.98); add ΔE / worst-region / heatmap differ. | M | +| 3.6 | B-11 | Wire the conformance gate into CI (advisory first, then required once goldens are stable). | S | +| 3.7 | B-8, B-9 | Shared `Fixture`/`Consumer` traits; corpus reorg. ODP/ODG/PPTX importers stay gated on the unbuilt ACID PPTX generator (ratified decision §5.1) — keep deferred, but record it in the conformance README. | M | + +**Exit criterion**: CI runs a golden-image conformance pass on every PR; +Spec 02's tracker rows flip from "decided" to "built". + +--- + +## Phase 4 — Editor completion: Spec 04/05 gaps + verified UX TODOs (P1, L) + +The audit calls Spec 04 "the least-complete 'shipped' spec" (§4). This phase +groups the user-visible editor work: spec milestones first, then the §2 UX +TODOs (merged with whatever Phase 0 confirms from F1–F7). + +### 4a. Spec milestones (model/architecture-gated first) + +| Task | Source | Detail | Effort | +|---|---|---|---| +| 4a.1 | Spec 04 M3 | Width-driven ribbon collapse cascade: condensed variant, overflow menu, per-group priority, hysteresis. | L | +| 4a.2 | Spec 04 M5 | Layout/References/Review ribbon tabs + `selected_object` contextual-tab signal (only 3 non-contextual tabs exist). | L | +| 4a.3 | Spec 05 | **Page** style family (`page_styles` catalog per ADR-0012) and **Table** family (`TableProps` conditional/banding regions); character-style editing form; per-family non-paragraph `Default` sources; Compact-tree breadcrumb (M7). | L | +| 4a.4 | Spec 03 | Metadata-panel label stacking <250 px (R-13g); responsive doc type-scale (M4); real `Viewport.zoom`. | M | +| 4a.5 | Spec 04 M6 | Touch posture + cursor-into-new-cell after insert. | M | + +### 4b. Editing-core TODOs (§2) + +| Task | Topic | Detail | Effort | +|---|---|---|---| +| 4b.1 | `3b-3` | Finish cross-page navigation: left/right at page edges; `page_index` recompute after split/merge (currently `None`). | M | +| 4b.2 | `formatting` | Multi-block-selection formatting (today clamped to the focus paragraph) — `editor_formatting.rs:106`. | M | +| 4b.3 | `undo-dirty` | Saved-vs-undo-stack clean tracking. **Depends on Save existing** — sequence after the Save/Save-As outcome of Phase 0 (F-findings). Also the natural moment to do Spec 01's typed `SaveError` residual. | M | +| 4b.4 | `nested-nav` | Sibling-path navigation inside cell/note bodies (`navigation.rs:138,174`). | S | +| 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | + +### 4c. Shell/UX polish TODOs (§2) — batchable + +| Task | Topics | Detail | Effort | +|---|---|---|---| +| 4c.1 | `ux` | Confirm-before-delete dialog in all three apps' Home (delete is immediate today). Small, user-data-protecting — do first in 4c. | S | +| 4c.2 | `a11y` | Expand invisible status-bar touch targets to `TOUCH_MIN` (WCAG 2.5.8 is a stated project convention). | S | +| 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | +| 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | + +--- + +## Phase 5 — Rendering & format fidelity backlog (P2, ongoing) + +Locally-actionable fidelity items from §2 and §5 (upstream-gated ones are in +the Watch list). Every task here must update `docs/fidelity-status.md`. + +| Task | Source | Detail | Effort | +|---|---|---|---| +| 5.1 | `tab-default` | Honour `DocumentSettings.default_tab_stop_pt` instead of the hardcoded 36 pt (`para.rs:648`). Pairs naturally with 1.1 (`tab_stops` round-trip). | S | +| 5.2 | `underline-style` / `strikethrough-style` | Double/Dotted/Dash/Wave underline, double strikethrough (all render Single today). Decoration geometry is ours (drawn in `loki-vello`), not Parley-gated. | M | +| 5.3 | `spell-baseline` | Tighten squiggle to the run underline offset (`para.rs:1619`). | S | +| 5.4 | `list-picture-bullet` | Picture bullets (fallback is `•`) — image plumbing already exists for block images. | M | +| 5.5 | `pdf-rotate` | Rotation transform in PDF export (`pdf/src/page.rs:83`); unlocks the "PDF clip/rotate paint" registry row. | M | +| 5.6 | gap #12 / `floating-image` | External-URL images render a grey placeholder (`loki-vello/src/image.rs:34`) + detect "floating" class for inline images (`resolve.rs:705`). | M | +| 5.7 | `odf-master-page` | ODF master-page transitions (`odf/reader/styles.rs:200`); pairs with the `style:default-style` registry row. | M | +| 5.8 | `omml` | OMML↔MathML: delimiters, n-ary, matrices, accents (`docx/omml/mod.rs:20`). | L | +| 5.9 | gaps #23–#30 tail | Kerning, orphan/widow control, `border_between`, DocxSettings, content controls, language tags — schedule individually from the fidelity registry; orphan/widow is the highest-value (visible in any multi-page doc). | L (aggregate) | +| 5.10 | registry | Page/column geometry set: even/odd blank pages, unequal column widths, column height balancing; PDF font subsetting + ICC/CMYK; EPUB math/fields/comments. | L (aggregate) | +| 5.11 | `link-click` | Interactive hyperlink hit-testing (visual hint only today) — spans layout (`resolve.rs:689`, `items.rs:125`, `para.rs:203`) and renderer (`scene.rs:519`). | M | + +--- + +## Phase 6 — Performance & memory (P2, M–L) + +From audit §5 (memory-audit + perf tails) and §2. Re-measure with the +`loki-bench` harness before and after each item — do not fix unverified perf +findings (P-3/P-5/P-6 were "not re-driven"). + +| Task | Source | Detail | +|---|---|---| +| 6.1 | memory F3 | Drop preserved layout for inactive tabs (`sessions.rs:39` retains `Arc`); coordinate with Spec 06 BM-8 (inactive-tab layout retention policy) so one design serves both. | +| 6.2 | memory F5 | Share the render `FontDataCache` across tiles (per-tile `page_paint_source.rs:53` vs shared `DocPageSource`); same item as Spec 06 BM-9 (per-tile font-byte dedup). | +| 6.3 | `split-optimise` | Y-range item filter to avoid GPU clipping (Option B; Option A shipped) — `para.rs:409`. | +| 6.4 | `partial-render` | Viewport clipping / direct `node.scroll_offset` (`scene.rs:148`, `editor_pointer.rs:139`) — partially gated on the vendored blitz-dom scroll API; do the locally-possible part. | +| 6.5 | audit P-3/P-5/P-6 | Re-measure first (bench), then fix if confirmed: glyph-run scans, coarse cache invalidation, cold-path clones. | +| 6.6 | Spec 06 tails | BM-3 render-cost proxy executes once Phase 3.3 lands `vello_cpu`; GPU frame-time (`device` feature) and on-device RSS recalibration remain device-gated — keep deferred, tracked in Spec 06. | + +--- + +## Phase 7 — Code-quality debt (P2/P3, ongoing ratchet) + +| Task | Source | Detail | +|---|---|---| +| 7.1 | Q-1 / §6 | **300-line split pass**: burn down the 35-file baseline, starting with the files other phases must touch (`para.rs` 1979, `flow.rs` 1953 — both also carry Phase 4/5 TODOs) so the split happens *before* feature work grows them further. Use the two established techniques (inline-test extraction, directory split); update the baseline with `--update` per split. | +| 7.2 | Q-2 | App-shell duplication: extract the common per-app `routes/` + `shell.rs` scaffolding into `loki-app-shell` (it already hosts `android_main!` and `SpellService`). | +| 7.3 | Q-3/Q-4 | Writer error-swallows (301 `let _ =`) and the ~100 `#[allow]` (incl. 32 `dead_code` in OOXML): ratchet, don't big-bang — add a CI count-ratchet script (same pattern as the file ceiling) and reduce opportunistically when touching a file. | +| 7.4 | audit S-1b/S-2/S-3/S-5 | Security tails: nested-table drop, dimension clamp, UTF-16 odd byte, XXE comment. Small, parser-local; batch into one hardening pass with fuzz-style regression tests. | +| 7.5 | T-2/T-3/T-5 | Test tails: ODT export coverage, per-case DOCX/XLSX round-trips, hard PPTX cases — grow alongside Phase 3's conformance corpus rather than as a separate effort. | +| 7.6 | Spec 01 residuals | `clippy::pedantic` + allow-list, `no_hardcoded_layout_dims` dylint, `cargo udeps` dead-`pub` sweep, Android target build verification in CI. Deliberate residuals; schedule after 7.1 has reduced churn. | + +--- + +## Watch list — upstream-gated, not schedulable (re-check on every dependency bump) + +No local work can close these; the action is to **re-verify the removal +condition whenever the pinned dependency moves** (per the "Upgrading Dioxus" +procedure in `docs/patches.md`). + +- **The five Dioxus/Blitz patches** (`dioxus-native`, `dioxus-native-dom`, `blitz-shell`, `blitz-net`, `blitz-dom`) — each has its removal condition in `docs/patches.md`; none met as of 0.7.9 / 0.2.x. +- **Parley**: native `BaselineShift` (super/sub is already visually correct via the manual offset — S-3), bidi/RTL direction API (gap #19), inline image boxes (`inline-image-flow`). +- **Vello**: blur primitive for true soft text shadow (`shadow` — hard offset copy today). +- **Blitz CSS**: `white-space: nowrap`, `text-overflow: ellipsis`, `:hover`, `scrollbar-width`, SVG rendering, `position: fixed` (collapses to `absolute`) — runtime-verify each on every Blitz bump and update the `CLAUDE.md` confirmed/unconfirmed lists. +- **Platform quirks (permanent)**: Mali-G715 Vulkan device-loss workarounds, Android 16 double `ANativeActivity_onCreate`, Word/OOXML file quirks — documented COMPAT, never removable. +- **Headless/server deferrals with in-code TODOs**: `TODO(headless-c025)` (apalis job queue), `TODO(headless-c021/c022/c023-discovery/c027/c028)`, `TODO(kms)`, `TODO(ws-membership)` — deliberate spec deferrals (ADR C021–C028); schedule when the server milestone that needs them is planned, not before. + +## Out of scope — deliberate post-MVP (do not schedule) + +Audit §7: Loki Calc / Loki Slides post-MVP items (virtualization >500×52, +richer formulas, charts, PPTX image/group export, masters/layouts, ODP import, +shape editing, etc.) remain governed by +`docs/mvp-scope-spreadsheet-presentation-2026-06-13.md`. Two exceptions worth +pulling forward when either app is next touched, because they violate suite +conventions rather than MVP scope: + +1. **i18n bypass** — both apps hardcode user-visible strings, against the "never hardcode" rule; migrate to `fl!()` opportunistically. +2. **Zero tests** — add smoke tests for whatever Phase 0 confirms from F1/F2 (edit loss, no-op delete/copy) before fixing them. + +--- + +## Suggested sequencing + +``` +Now (parallel): Phase 0 (verify F1–F7) Phase 2 (loki-file-access) Task 7.1 (split para.rs/flow.rs) +Next: Phase 1 (CRDT integrity) Phase 3 (conformance foundation) +Then: Phase 4 (editor completion, informed by Phase 0) +Ongoing ratchet: Phase 5 (fidelity), Phase 6 (perf, bench-gated), Phase 7 (quality) +Every dep bump: Watch list re-verification +``` + +Rationale for the front of the queue: Phase 0 is cheap and de-risks Phase 4; +Phase 2 is the only patch we fully control and shrinks the patch surface; +splitting `para.rs`/`flow.rs` first prevents every later phase that touches +them (5.1–5.4, 4b.5, 6.3) from fighting the ceiling ratchet; Phase 1 protects +user data; Phase 3 builds the verification infrastructure that keeps Phases 4–5 +honest. From ee02cfc6386addaaf441811bd579edce92bc827b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:44:41 +0000 Subject: [PATCH 02/27] =?UTF-8?q?docs:=20Phase=200=20=E2=80=94=20re-drive?= =?UTF-8?q?=20audit=20findings=20F1-F7,=20record=20verdicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the F1-F7 verification addendum (§9) to the deferred-features audit: F1/F2/F4 resolved, F3 largely resolved (dirty-tab close confirmation remains), F5 resolved by removal of the retier pipeline in favor of viewport-window mounting, F6/F7 partial with per-sub-item verdicts. Corrects the 2026-06-10 audit's inaccurate claim that off-screen pages downsample by viewport distance, points its stale F1-F7 note at the new addendum, folds the confirmed-open items into the remediation plan (tasks 4b.6/4b.7, 4c.5, 6.7, watch list), and fixes two stale in-code comments found during verification (TODO(undo-dirty) 'Save not implemented'; hit_test.rs 'scroll_offset = 0.0'). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/audit-2026-06-10.md | 20 +++++++++---- docs/deferred-features-audit-2026-07-04.md | 31 +++++++++++++++++++++ docs/deferred-features-plan-2026-07-04.md | 9 ++++-- loki-text/src/editing/hit_test.rs | 5 ++-- loki-text/src/routes/editor/editor_state.rs | 10 ++++--- 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/docs/audit-2026-06-10.md b/docs/audit-2026-06-10.md index e20ae26f..58757c9e 100644 --- a/docs/audit-2026-06-10.md +++ b/docs/audit-2026-06-10.md @@ -15,9 +15,14 @@ crash), **High**, **Medium**, **Low**. > aggregate caps, `MAX_MATERIALIZED_*`, and checked arithmetic; **S4** (unbounded > ODT recursion) enforces `MAX_NESTING_DEPTH` (`odt/reader/document.rs`); and > **C1** (lists/tables/figures collapsing to horizontal rules on the first CRDT -> write) is fixed — `loro_bridge/write.rs` writes native `Block::Table` etc. The -> app-layer findings (F1–F7) were **not** re-verified in that pass; treat as -> likely-open. Original findings preserved below unchanged. +> write) is fixed — `loro_bridge/write.rs` writes native `Block::Table` etc. +> +> **Update (2026-07-04):** the app-layer findings F1–F7 **were re-driven** — +> per-sub-item verdicts live in the addendum of +> [`deferred-features-audit-2026-07-04.md`](deferred-features-audit-2026-07-04.md) +> (§9). Headline: F1/F2/F4 resolved, F3 largely resolved (close-without-confirm +> remains), F5 resolved by *removal* (see correction in §5 below), F6/F7 +> partially resolved. Original findings preserved below unchanged. --- @@ -270,10 +275,15 @@ The editor pipeline is `document_to_loro` on open → mutate → cache implementation. `appthere-canvas` re-exports its `PageCache`, `CacheTier`, tier policy, scroll state, key trait, and GPU texture utilities rather than carrying duplicate copies, so the renderer's existing - `appthere_canvas::*` paths resolve to it unchanged. Paired with the F5 fix + `appthere_canvas::*` paths resolve to it unchanged. ~~Paired with the F5 fix below, off-screen pages now downsample by viewport distance, bounding resident texture memory instead of keeping the whole document at full - resolution. + resolution.~~ **Correction (2026-07-04):** there is no distance-based + downsampling/retier cache — the settle/retier pipeline was *deleted*, and + texture memory is bounded by viewport-window **mounting** instead + (`loki-renderer/src/virtualize.rs` `visible_window`; off-window pages are + blank placeholders). `page_paint_source.rs` states "there is no + resolution-tiering cache." - **Empty module**: `loki-text/src/components/mod.rs` (leftover from the `document_source.rs` removal) still exported from `lib.rs:15`. The CLAUDE.md known-exceptions table is stale (references the deleted file). diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index e1831913..29477341 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -179,3 +179,34 @@ Pure documentation hygiene — no functional change, but stops the docs from mis 4. Reclassify the Spec 02 **"resolved-as-decision"** items (Gelasio, schemas, goldens, SSIM, `vello_cpu`) so they read as *decided, not built*. Everything else in §2–§7 is a genuine, correctly-documented deferral. + +--- + +## 9. Addendum (2026-07-04, later the same day): F1–F7 re-driven + +The §5 closing note flagged the app-layer findings F1–F7 (audit-2026-06-10) as +"not individually re-driven; treat as likely-open pending a focused check." +That focused check has now been done (Phase 0 of +[`deferred-features-plan-2026-07-04.md`](deferred-features-plan-2026-07-04.md)); +per-sub-item verdicts against HEAD `20b05a6`: + +| # | Original claim | Verdict | Evidence / residual | +|---|---|---|---| +| F1 | Presentation editor is a hardcoded demo; no load/save | **RESOLVED** (core) | Real PPTX import (`editor_load.rs:40-54` → `PptxImport`) and export with Save/Save As (`editor_save.rs`, `editor_inner.rs:87-149`); ODP is a typed `UnsupportedFormat` (deferred by MVP scope, not faked). **Residual:** in-memory edits still discarded on tab switch (`editor_inner.rs:50-57` resets `doc` on path change) — now *warned* via dirty indicator, no longer silent. | +| F2 | Recents Delete/Copy are silent no-ops (3 apps) | **RESOLVED** | `FileAccessToken::delete()` / `copy_bytes_to()` exist (`patches/loki-file-access/src/token.rs:116,132`) and all three `home.rs` handlers use them; failures surfaced via `pick_error` + `errors.ftl` keys. Remaining `TODO(ux)` confirm-dialog is tracked separately (plan 4c.1). | +| F3 | Edits lost on tab switch; dirty flag never set | **LARGELY RESOLVED** | Per-tab retention via `loki-text/src/sessions.rs` `DocSession` (stash/restore in `editor_path_sync.rs:42-154`); dirty tracks a generation baseline (`editor_inner.rs:465-476`), cleared on save. **Residual (F3c):** `shell.rs:101-145` still closes dirty tabs with no confirmation, discarding the stashed session. | +| F4 | Untitled documents cannot be saved; no Ctrl+S | **RESOLVED** | Save As via `pick_file_to_save` (`editor_inner.rs:484-535`); Ctrl/Cmd+S bound (`editor_keydown.rs:60-67`) routing untitled → Save As. | +| F5 | Settle/retier pipeline wired to dead channels | **RESOLVED (by removal)** | The pipeline was deleted, not fixed: virtualization now bounds memory by mounting only viewport-window pages (`virtualize.rs` `visible_window`, `document_view.rs:290`). The 06-10 audit's §5 claim of "downsample by viewport distance" was wrong about the mechanism — corrected there. **Residual:** `DocumentViewProps::eq` still hardwired `false` (`document_view.rs:143-147`) — now a benign over-render, capped by `PageTile`'s own `PartialEq`. | +| F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom controls dead in all 3 apps; spreadsheet ribbon tab-select/collapse dead**. STILL-OPEN: F6a hooks in conditionals/loops (`recent_files.rs:45,96,219`), F6c typing/Backspace ignore selection + no clipboard, F6f synchronous save/load on UI thread. | +| F7 | Low grab-bag | **PARTIAL** | RESOLVED: F7d safe-area insets (RwLock + reactive version signal + resize sensor). PARTIAL: F7c — loki-text page number now live; word count still empty everywhere. STILL-OPEN: F7a `AtHomeTab` responsive layout (`viewport_width` fixed 375.0, never adopted `use_breakpoint()`), F7b index-based list keys + `active_slide_idx` not adjusted on delete-before-active, F7e debug leftovers in vendored patches, F7f `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`). | + +**Two stale in-code comments found during verification were fixed in the same +pass:** the `TODO(undo-dirty)` parenthetical ("Save not implemented" — Save now +exists; remaining work is the undo-stack clean checkpoint) and the +`editing/hit_test.rs` doc-comment claiming `scroll_offset = 0.0`. + +The confirmed-open items fold into the plan as follows: F3c + F1-residual +(close/switch protection for dirty work) join Phase 4b; F6a/F6c/F6d/F6f and +F7a/F7b/F7c join the Phase 4b/4c backlog; F7e/F7f are patch-tree fixes queued +with the next patch re-vendor (Watch list); the F5 `PartialEq` residual joins +Phase 6 (perf polish). diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index e0078037..80544396 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -30,8 +30,8 @@ exact stale-doc failure mode the audit was written to catch. | Task | Detail | Exit criterion | |---|---|---| -| 0.1 | Re-drive F1–F7 against current `loki-text` / `loki-presentation` / `loki-spreadsheet` code; record each as open or resolved in a short addendum to the audit doc. | Each of F1–F7 has a verified status with `file:line` evidence. | -| 0.2 | Fold the confirmed-open subset into Phase 4 (they are all editor/app-shell UX items). | Phase 4 backlog updated. | +| 0.1 | ✅ **Done 2026-07-04** — F1–F7 re-driven; verdicts in the audit's new §9 addendum. Headline: F1/F2/F4 resolved, F3 largely resolved, F5 resolved-by-removal, F6/F7 partial. | Each of F1–F7 has a verified status with `file:line` evidence. | +| 0.2 | ✅ **Done 2026-07-04** — confirmed-open subset folded into Phases 4/6 and the Watch list (see 4b.6/4b.7, 4c.5, 6.7 below). | Phase 4 backlog updated. | --- @@ -120,6 +120,8 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4b.3 | `undo-dirty` | Saved-vs-undo-stack clean tracking. **Depends on Save existing** — sequence after the Save/Save-As outcome of Phase 0 (F-findings). Also the natural moment to do Spec 01's typed `SaveError` residual. | M | | 4b.4 | `nested-nav` | Sibling-path navigation inside cell/note bodies (`navigation.rs:138,174`). | S | | 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | +| 4b.6 | F3c + F1 residual (audit §9) | Dirty-work protection: confirm-before-close for dirty tabs in loki-text (`shell.rs:101-145` discards the stashed session silently) and save-prompt/retention on presentation tab switch (`editor_inner.rs:50-57`). | S–M | +| 4b.7 | F6c + F6f (audit §9) | Selection-aware typing/Backspace (replace active selection), clipboard (copy/cut/paste), and move save/load I/O off the UI thread (`editor_ribbon.rs:93`, `editor_load.rs:56-101`). | M | ### 4c. Shell/UX polish TODOs (§2) — batchable @@ -129,6 +131,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4c.2 | `a11y` | Expand invisible status-bar touch targets to `TOUCH_MIN` (WCAG 2.5.8 is a stated project convention). | S | | 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | | 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | +| 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | Extract recent-file rows into child components (hooks-in-loops); wire zoom controls (all 3 apps) + spreadsheet ribbon tab-select/collapse; adopt `use_breakpoint()` in `AtHomeTab`; stable list keys + `active_slide_idx` fix-up on slide delete; word count in the status bar. | M | --- @@ -167,6 +170,7 @@ findings (P-3/P-5/P-6 were "not re-driven"). | 6.4 | `partial-render` | Viewport clipping / direct `node.scroll_offset` (`scene.rs:148`, `editor_pointer.rs:139`) — partially gated on the vendored blitz-dom scroll API; do the locally-possible part. | | 6.5 | audit P-3/P-5/P-6 | Re-measure first (bench), then fix if confirmed: glyph-run scans, coarse cache invalidation, cold-path clones. | | 6.6 | Spec 06 tails | BM-3 render-cost proxy executes once Phase 3.3 lands `vello_cpu`; GPU frame-time (`device` feature) and on-device RSS recalibration remain device-gated — keep deferred, tracked in Spec 06. | +| 6.7 | F5 residual (audit §9) | Replace the hardwired-`false` `DocumentViewProps::eq` (`document_view.rs:143-147`) with a real comparison — benign today (PageTile eq caps the cost) but pure over-render. | --- @@ -194,6 +198,7 @@ procedure in `docs/patches.md`). - **Vello**: blur primitive for true soft text shadow (`shadow` — hard offset copy today). - **Blitz CSS**: `white-space: nowrap`, `text-overflow: ellipsis`, `:hover`, `scrollbar-width`, SVG rendering, `position: fixed` (collapses to `absolute`) — runtime-verify each on every Blitz bump and update the `CLAUDE.md` confirmed/unconfirmed lists. - **Platform quirks (permanent)**: Mali-G715 Vulkan device-loss workarounds, Android 16 double `ANativeActivity_onCreate`, Word/OOXML file quirks — documented COMPAT, never removable. +- **Patch-tree fixes queued for the next re-vendor** (audit §9 F7e/F7f): strip the `[LOKI/head]` / `println!` / `dbg!` debug leftovers from the vendored patches, and fix the `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`, should be `&= !Main`) — batch these with the next Dioxus/Blitz patch re-vendor rather than churning the vendored tree now. - **Headless/server deferrals with in-code TODOs**: `TODO(headless-c025)` (apalis job queue), `TODO(headless-c021/c022/c023-discovery/c027/c028)`, `TODO(kms)`, `TODO(ws-membership)` — deliberate spec deferrals (ADR C021–C028); schedule when the server milestone that needs them is planned, not before. ## Out of scope — deliberate post-MVP (do not schedule) diff --git a/loki-text/src/editing/hit_test.rs b/loki-text/src/editing/hit_test.rs index ce6bbb7d..d9197aad 100644 --- a/loki-text/src/editing/hit_test.rs +++ b/loki-text/src/editing/hit_test.rs @@ -21,8 +21,9 @@ //! Spec 01 audit A-1.) //! - `canvas_origin.y` = `TOOLBAR_HEIGHT_TOP + SPACE_6` (exact from tokens). //! -//! - `scroll_offset` = 0.0 (Blitz does not expose `node.scroll_offset` to -//! Dioxus components; wired as a TODO once the API is available). +//! - `scroll_offset` = the live scroll position mirrored from the canvas +//! `onscroll` handler (`editor_canvas.rs` → `scroll_metrics`), threaded in +//! by the pointer handlers (`editor_pointer.rs`). //! //! All geometry inside this function works in layout **points** (1 pt = 1/72 in). //! The conversion from CSS logical pixels is applied once at entry: diff --git a/loki-text/src/routes/editor/editor_state.rs b/loki-text/src/routes/editor/editor_state.rs index 0fb100d0..d526a684 100644 --- a/loki-text/src/routes/editor/editor_state.rs +++ b/loki-text/src/routes/editor/editor_state.rs @@ -115,10 +115,12 @@ pub(super) struct EditorState { pub subscript_active: Signal, /// Loro undo manager — `None` until the document loads. /// - /// // TODO(undo-dirty): `can_undo` does not track whether the document is - /// saved relative to the undo stack. When a Save action is implemented, - /// call `UndoManager::record_new_checkpoint()` to mark the clean state so - /// the ribbon Save button can be disabled when there is nothing to save. + /// // TODO(undo-dirty): the tab dirty indicator already tracks + /// saved-vs-edited via a generation baseline (`editor_inner.rs`, + /// `baseline_gen`), but the undo stack itself has no clean checkpoint. + /// Call `UndoManager::record_new_checkpoint()` on Save so undoing back + /// to the saved state clears dirty and the ribbon Save button can be + /// disabled when there is nothing to save. pub undo_manager: Signal>, /// Whether Ctrl+Z is currently applicable (derived from `undo_manager`). pub can_undo: Signal, From ca3744974b9d41bfde7f4b927b028e637620b7e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:44:52 +0000 Subject: [PATCH 03/27] fix(loro-bridge): round-trip tab_stops and paragraph background_color MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both fields were written to the CRDT as Debug strings — tab_stops had no reader at all, and the paragraph background_color reader parsed hex only, so both were silently dropped on the first document_to_loro → loro_to_document cycle (deferred-features audit §6). - tab_stops: structured 'pos:Alignment:Leader;…' codec in loro_bridge/decode.rs, written under PROP_TAB_STOPS and read back in reconstruct_para_props. Pre-codec Debug strings decode as absent. - background_color: new total DocumentColor codec (loro_bridge/color_codec.rs) covering Rgb/Cmyk/Theme/Transparent, so non-Rgb paragraph backgrounds no longer collapse or drop. - Extracted the LoroMap accessors into loro_bridge/map_get.rs to keep props_read.rs under the 300-line ceiling. - Round-trip tests: bridge_tab_stops_roundtrip, bridge_para_background_color_roundtrip (all four color variants). Phase 1.1/1.2 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- CLAUDE.md | 4 +- loki-doc-model/src/loro_bridge/color_codec.rs | 100 ++++++++++++++++++ loki-doc-model/src/loro_bridge/decode.rs | 67 ++++++++++++ loki-doc-model/src/loro_bridge/map_get.rs | 35 ++++++ loki-doc-model/src/loro_bridge/mod.rs | 2 + loki-doc-model/src/loro_bridge/props_read.rs | 44 +++----- loki-doc-model/src/loro_bridge/read.rs | 6 +- loki-doc-model/src/loro_bridge/write.rs | 9 +- loki-doc-model/src/loro_schema.rs | 1 + loki-doc-model/tests/loro_bridge_gap_tests.rs | 93 +++++++++++++++- 10 files changed, 322 insertions(+), 39 deletions(-) create mode 100644 loki-doc-model/src/loro_bridge/color_codec.rs create mode 100644 loki-doc-model/src/loro_bridge/map_get.rs diff --git a/CLAUDE.md b/CLAUDE.md index 85139075..63715013 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -199,8 +199,8 @@ but are **not perfectly round-tripped through the Loro CRDT**. | Field(s) | Status | Priority | |---|---|---| -| `tab_stops` | Written as unreadable Debug string; not read back. | Medium | -| `background_color` (paragraph) | Written as Debug string; not decoded on read. | Low | +| `tab_stops` | **DONE** (2026-07-04) — structured `"pos:Align:Leader;…"` codec (`loro_bridge/decode.rs`) written and read back; tested by `bridge_tab_stops_roundtrip`. Pre-fix Debug strings decode as absent. | — | +| `background_color` (paragraph) | **DONE** (2026-07-04) — total `DocumentColor` codec (`loro_bridge/color_codec.rs`, covers Rgb/Cmyk/Theme/Transparent) written and read back; tested by `bridge_para_background_color_roundtrip`. | — | | `DocumentMeta` / `DublinCoreMeta` | Round-trips **through the Loro CRDT** (`loro_bridge::meta`) **and is written back on export** — core properties + extended Dublin Core reach DOCX (`docProps/core.xml` + `custom.xml`) and ODT (`meta.xml`), tested by `metadata_round_trip.rs` / `extended_dublin_core_round_trips`. Remaining tail (not the Loro bridge): custom user properties, `meta:editing-duration`, and OOXML `docProps/app.xml` are still not written. | Low | --- diff --git a/loki-doc-model/src/loro_bridge/color_codec.rs b/loki-doc-model/src/loro_bridge/color_codec.rs new file mode 100644 index 00000000..18918713 --- /dev/null +++ b/loki-doc-model/src/loro_bridge/color_codec.rs @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Codec for [`DocumentColor`] values in the Loro CRDT schema. +//! +//! Unlike [`DocumentColor::to_hex`], this codec is total: every variant — +//! including `Cmyk`, `Theme`, and `Transparent` — has a stable string +//! encoding, so a color survives a document_to_loro → loro_to_document +//! round-trip without collapsing to Rgb or being dropped. +//! +//! Encodings: +//! - `Rgb` → `#RRGGBB` +//! - `Cmyk` → `cmyk:C:M:Y:K` (components in `[0, 1]`) +//! - `Theme` → `theme:Slot:tint` +//! - `Transparent` → `transparent` + +use loki_primitives::color::{CmykColor, DocumentColor, ThemeColorSlot}; + +/// Encode any [`DocumentColor`] as a compact schema string. +pub(super) fn encode_document_color(c: &DocumentColor) -> String { + match c { + DocumentColor::Rgb(_) => c.to_hex().unwrap_or_else(|| String::from("transparent")), + DocumentColor::Cmyk(cmyk) => format!( + "cmyk:{}:{}:{}:{}", + cmyk.cyan(), + cmyk.magenta(), + cmyk.yellow(), + cmyk.key() + ), + DocumentColor::Theme { slot, tint } => { + format!("theme:{}:{}", encode_theme_slot(*slot), tint) + } + DocumentColor::Transparent => String::from("transparent"), + // `DocumentColor` is #[non_exhaustive]; a variant this codec does not + // know yet cannot be encoded faithfully. Encode as transparent rather + // than panicking — extending the codec is the real fix when the enum + // grows. + _ => String::from("transparent"), + } +} + +/// Decode a string produced by [`encode_document_color`]. +pub(super) fn decode_document_color(s: &str) -> Option { + if s == "transparent" { + return Some(DocumentColor::Transparent); + } + if let Some(rest) = s.strip_prefix("cmyk:") { + let mut parts = rest.splitn(4, ':'); + let c: f32 = parts.next()?.parse().ok()?; + let m: f32 = parts.next()?.parse().ok()?; + let y: f32 = parts.next()?.parse().ok()?; + let k: f32 = parts.next()?.parse().ok()?; + return Some(DocumentColor::Cmyk(CmykColor::new(c, m, y, k))); + } + if let Some(rest) = s.strip_prefix("theme:") { + let mut parts = rest.splitn(2, ':'); + let slot = decode_theme_slot(parts.next()?)?; + let tint: f32 = parts.next()?.parse().ok()?; + return Some(DocumentColor::Theme { slot, tint }); + } + DocumentColor::from_hex(s).ok() +} + +fn encode_theme_slot(slot: ThemeColorSlot) -> &'static str { + match slot { + ThemeColorSlot::Dark1 => "Dark1", + ThemeColorSlot::Dark2 => "Dark2", + ThemeColorSlot::Light1 => "Light1", + ThemeColorSlot::Light2 => "Light2", + ThemeColorSlot::Accent1 => "Accent1", + ThemeColorSlot::Accent2 => "Accent2", + ThemeColorSlot::Accent3 => "Accent3", + ThemeColorSlot::Accent4 => "Accent4", + ThemeColorSlot::Accent5 => "Accent5", + ThemeColorSlot::Accent6 => "Accent6", + ThemeColorSlot::Hyperlink => "Hyperlink", + ThemeColorSlot::FollowedHyperlink => "FollowedHyperlink", + // #[non_exhaustive] guard for slots added upstream — map to Dark1 + // (text default) until the codec learns the new slot. + _ => "Dark1", + } +} + +fn decode_theme_slot(s: &str) -> Option { + match s { + "Dark1" => Some(ThemeColorSlot::Dark1), + "Dark2" => Some(ThemeColorSlot::Dark2), + "Light1" => Some(ThemeColorSlot::Light1), + "Light2" => Some(ThemeColorSlot::Light2), + "Accent1" => Some(ThemeColorSlot::Accent1), + "Accent2" => Some(ThemeColorSlot::Accent2), + "Accent3" => Some(ThemeColorSlot::Accent3), + "Accent4" => Some(ThemeColorSlot::Accent4), + "Accent5" => Some(ThemeColorSlot::Accent5), + "Accent6" => Some(ThemeColorSlot::Accent6), + "Hyperlink" => Some(ThemeColorSlot::Hyperlink), + "FollowedHyperlink" => Some(ThemeColorSlot::FollowedHyperlink), + _ => None, + } +} diff --git a/loki-doc-model/src/loro_bridge/decode.rs b/loki-doc-model/src/loro_bridge/decode.rs index 29810784..8bfdd235 100644 --- a/loki-doc-model/src/loro_bridge/decode.rs +++ b/loki-doc-model/src/loro_bridge/decode.rs @@ -12,6 +12,7 @@ use crate::style::props::char_props::{ HighlightColor, StrikethroughStyle, UnderlineStyle, VerticalAlign, }; use crate::style::props::para_props::{LineHeight, ParagraphAlignment, Spacing}; +use crate::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; @@ -139,6 +140,72 @@ pub(super) fn decode_line_height(s: &str) -> Option { } } +// ── Tab-stop codec ─────────────────────────────────────────────────────────── + +/// Encode a tab-stop list as `"position_pt:Alignment:Leader"` entries joined +/// by `';'` (e.g. `"36:Left:None;144:Decimal:Dot"`). +pub(super) fn encode_tab_stops(stops: &[TabStop]) -> String { + stops + .iter() + .map(|ts| { + let alignment = match ts.alignment { + TabAlignment::Left => "Left", + TabAlignment::Right => "Right", + TabAlignment::Center => "Center", + TabAlignment::Decimal => "Decimal", + TabAlignment::Clear => "Clear", + }; + let leader = match ts.leader { + TabLeader::None => "None", + TabLeader::Dot => "Dot", + TabLeader::Dash => "Dash", + TabLeader::Underscore => "Underscore", + TabLeader::Heavy => "Heavy", + TabLeader::MiddleDot => "MiddleDot", + }; + format!("{}:{}:{}", ts.position.value(), alignment, leader) + }) + .collect::>() + .join(";") +} + +/// Decode a tab-stop list produced by [`encode_tab_stops`]. Returns `None` +/// for unparseable input (including the pre-codec Debug-string encoding), +/// which callers treat as an absent property. +pub(super) fn decode_tab_stops(s: &str) -> Option> { + if s.is_empty() { + return Some(Vec::new()); + } + let mut stops = Vec::new(); + for entry in s.split(';') { + let mut fields = entry.splitn(3, ':'); + let position: f64 = fields.next()?.parse().ok()?; + let alignment = match fields.next()? { + "Left" => TabAlignment::Left, + "Right" => TabAlignment::Right, + "Center" => TabAlignment::Center, + "Decimal" => TabAlignment::Decimal, + "Clear" => TabAlignment::Clear, + _ => return None, + }; + let leader = match fields.next()? { + "None" => TabLeader::None, + "Dot" => TabLeader::Dot, + "Dash" => TabLeader::Dash, + "Underscore" => TabLeader::Underscore, + "Heavy" => TabLeader::Heavy, + "MiddleDot" => TabLeader::MiddleDot, + _ => return None, + }; + stops.push(TabStop { + position: Points::new(position), + alignment, + leader, + }); + } + Some(stops) +} + // ── Border codec ───────────────────────────────────────────────────────────── /// Encode a [`Border`] as a compact `"Style:width_pt:color:spacing_pt"` string. diff --git a/loki-doc-model/src/loro_bridge/map_get.rs b/loki-doc-model/src/loro_bridge/map_get.rs new file mode 100644 index 00000000..ddaf870f --- /dev/null +++ b/loki-doc-model/src/loro_bridge/map_get.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Typed value accessors for [`LoroMap`] entries, shared by the bridge read +//! path (`read.rs`, `props_read.rs`). +//! +//! Split from `props_read.rs` to keep individual files under the 300-line +//! ceiling. + +use loro::LoroMap; + +pub(super) fn get_str_from_map(map: &LoroMap, key: &str) -> Option { + map.get(key) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_string().ok()) + .map(|s| s.to_string()) +} + +pub(super) fn get_f64_from_map(map: &LoroMap, key: &str) -> Option { + map.get(key) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_double().ok()) +} + +pub(super) fn get_bool_from_map(map: &LoroMap, key: &str) -> Option { + map.get(key) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_bool().ok()) +} + +pub(super) fn get_i64_from_map(map: &LoroMap, key: &str) -> Option { + map.get(key) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_i64().ok()) +} diff --git a/loki-doc-model/src/loro_bridge/mod.rs b/loki-doc-model/src/loro_bridge/mod.rs index 1722f4ee..badecf1d 100644 --- a/loki-doc-model/src/loro_bridge/mod.rs +++ b/loki-doc-model/src/loro_bridge/mod.rs @@ -9,6 +9,7 @@ //! - `read` — deserialization (Loro → Loki) //! - `inlines` — inline content helpers shared by both directions +mod color_codec; mod comments; mod decode; mod incremental; @@ -16,6 +17,7 @@ mod incremental; mod inline_objects; mod inlines; mod inlines_read; +mod map_get; mod meta; mod opaque; mod props_read; diff --git a/loki-doc-model/src/loro_bridge/props_read.rs b/loki-doc-model/src/loro_bridge/props_read.rs index 4be65914..9d803612 100644 --- a/loki-doc-model/src/loro_bridge/props_read.rs +++ b/loki-doc-model/src/loro_bridge/props_read.rs @@ -5,10 +5,12 @@ //! //! Split from `read.rs` to keep individual files under the 300-line ceiling. +use super::color_codec::decode_document_color; use super::decode::{ decode_alignment, decode_border, decode_highlight_color, decode_line_height, decode_spacing, - decode_strikethrough, decode_underline, decode_vertical_align, + decode_strikethrough, decode_tab_stops, decode_underline, decode_vertical_align, }; +use super::map_get::{get_bool_from_map, get_f64_from_map, get_i64_from_map, get_str_from_map}; use crate::loro_schema::*; use crate::meta::language::LanguageTag; use crate::style::list_style::ListId; @@ -18,33 +20,6 @@ use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; use loro::LoroMap; -// ── Loro map value accessors ────────────────────────────────────────────────── - -pub(super) fn get_str_from_map(map: &LoroMap, key: &str) -> Option { - map.get(key) - .and_then(|v| v.into_value().ok()) - .and_then(|v| v.into_string().ok()) - .map(|s| s.to_string()) -} - -pub(super) fn get_f64_from_map(map: &LoroMap, key: &str) -> Option { - map.get(key) - .and_then(|v| v.into_value().ok()) - .and_then(|v| v.into_double().ok()) -} - -pub(super) fn get_bool_from_map(map: &LoroMap, key: &str) -> Option { - map.get(key) - .and_then(|v| v.into_value().ok()) - .and_then(|v| v.into_bool().ok()) -} - -pub(super) fn get_i64_from_map(map: &LoroMap, key: &str) -> Option { - map.get(key) - .and_then(|v| v.into_value().ok()) - .and_then(|v| v.into_i64().ok()) -} - // ── ParaProps reconstruction ────────────────────────────────────────────────── pub(super) fn reconstruct_para_props(block_map: &LoroMap) -> Option { @@ -168,6 +143,19 @@ pub(super) fn reconstruct_para_props(block_map: &LoroMap) -> Option { read_pt!(padding_left, PROP_PADDING_LEFT); read_pt!(padding_right, PROP_PADDING_RIGHT); + if let Some(s) = get_str_from_map(&props_map, PROP_TAB_STOPS) + && let Some(ts) = decode_tab_stops(&s) + { + props.tab_stops = Some(ts); + any = true; + } + if let Some(s) = get_str_from_map(&props_map, PROP_BACKGROUND_COLOR) + && let Some(c) = decode_document_color(&s) + { + props.background_color = Some(c); + any = true; + } + if any { Some(props) } else { None } } diff --git a/loki-doc-model/src/loro_bridge/read.rs b/loki-doc-model/src/loro_bridge/read.rs index 71966c05..ea983977 100644 --- a/loki-doc-model/src/loro_bridge/read.rs +++ b/loki-doc-model/src/loro_bridge/read.rs @@ -5,10 +5,8 @@ use super::BridgeError; use super::inlines_read::reconstruct_inlines; -use super::props_read::{ - get_bool_from_map, get_f64_from_map, get_i64_from_map, get_str_from_map, - reconstruct_char_props_from_map, reconstruct_para_props, -}; +use super::map_get::{get_bool_from_map, get_f64_from_map, get_i64_from_map, get_str_from_map}; +use super::props_read::{reconstruct_char_props_from_map, reconstruct_para_props}; use crate::content::attr::NodeAttr; use crate::content::block::Block; use crate::layout::header_footer::{HeaderFooter, HeaderFooterKind}; diff --git a/loki-doc-model/src/loro_bridge/write.rs b/loki-doc-model/src/loro_bridge/write.rs index 4f827949..6852301a 100644 --- a/loki-doc-model/src/loro_bridge/write.rs +++ b/loki-doc-model/src/loro_bridge/write.rs @@ -4,7 +4,10 @@ //! Serialization: Loki document model → Loro CRDT containers. use super::BridgeError; -use super::decode::{encode_alignment, encode_border, encode_line_height, encode_spacing}; +use super::color_codec::encode_document_color; +use super::decode::{ + encode_alignment, encode_border, encode_line_height, encode_spacing, encode_tab_stops, +}; use super::inlines::map_inlines; use crate::content::block::Block; use crate::loro_schema::*; @@ -184,10 +187,10 @@ pub(super) fn map_para_props(props: &ParaProps, map: &LoroMap) -> Result<(), Bri map.insert(PROP_PADDING_RIGHT, v.value())?; } if let Some(v) = &props.tab_stops { - map.insert(PROP_TAB_STOPS, format!("{:?}", v))?; + map.insert(PROP_TAB_STOPS, encode_tab_stops(v))?; } if let Some(v) = &props.background_color { - map.insert("background_color", format!("{:?}", v))?; + map.insert(PROP_BACKGROUND_COLOR, encode_document_color(v))?; } Ok(()) } diff --git a/loki-doc-model/src/loro_schema.rs b/loki-doc-model/src/loro_schema.rs index 8820becd..a763ac22 100644 --- a/loki-doc-model/src/loro_schema.rs +++ b/loki-doc-model/src/loro_schema.rs @@ -239,6 +239,7 @@ pub const PROP_PADDING_BOTTOM: &str = "padding_bottom"; pub const PROP_PADDING_LEFT: &str = "padding_left"; pub const PROP_PADDING_RIGHT: &str = "padding_right"; pub const PROP_TAB_STOPS: &str = "tab_stops"; +pub const PROP_BACKGROUND_COLOR: &str = "background_color"; // ----------------------------------------------------------------------------- // Section / PageLayout Keys diff --git a/loki-doc-model/tests/loro_bridge_gap_tests.rs b/loki-doc-model/tests/loro_bridge_gap_tests.rs index 54d1459c..1eff244f 100644 --- a/loki-doc-model/tests/loro_bridge_gap_tests.rs +++ b/loki-doc-model/tests/loro_bridge_gap_tests.rs @@ -4,7 +4,8 @@ //! Loro bridge round-trip tests for L-severity gap fixes. //! //! Verifies that language, border, padding, page_break_before, orphan_control, -//! and outline_level all survive a document_to_loro → loro_to_document cycle. +//! outline_level, tab_stops, and paragraph background_color all survive a +//! document_to_loro → loro_to_document cycle. use loki_doc_model::content::attr::NodeAttr; use loki_doc_model::content::block::Block; @@ -15,7 +16,8 @@ use loki_doc_model::meta::language::LanguageTag; use loki_doc_model::style::props::border::{Border, BorderStyle}; use loki_doc_model::style::props::char_props::CharProps; use loki_doc_model::style::props::para_props::ParaProps; -use loki_primitives::color::DocumentColor; +use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader, TabStop}; +use loki_primitives::color::{DocumentColor, ThemeColorSlot}; use loki_primitives::units::Points; fn round_trip(doc: &Document) -> Document { @@ -166,6 +168,93 @@ fn bridge_border_roundtrip() { ); } +// ── bridge_tab_stops_roundtrip ──────────────────────────────────────────────── + +fn styled_para_with_para(props: ParaProps) -> Block { + Block::StyledPara(loki_doc_model::content::block::StyledParagraph { + style_id: None, + direct_para_props: Some(Box::new(props)), + direct_char_props: None, + inlines: vec![Inline::Str("text".into())], + attr: NodeAttr::default(), + }) +} + +fn recovered_para_props(doc: &Document) -> ParaProps { + doc.sections[0] + .blocks + .iter() + .find_map(|b| { + if let Block::StyledPara(p) = b { + p.direct_para_props.as_deref().cloned() + } else { + None + } + }) + .expect("StyledPara with direct_para_props must survive round-trip") +} + +/// `ParaProps.tab_stops` must survive a Loro CRDT round-trip with position, +/// alignment, and leader intact (was written as an unreadable Debug string). +#[test] +fn bridge_tab_stops_roundtrip() { + let mut para_props = ParaProps::default(); + para_props.tab_stops = Some(vec![ + TabStop { + position: Points::new(36.0), + alignment: TabAlignment::Left, + leader: TabLeader::None, + }, + TabStop { + position: Points::new(144.5), + alignment: TabAlignment::Decimal, + leader: TabLeader::Dot, + }, + ]); + + let doc = single_block_doc(styled_para_with_para(para_props)); + let pp = recovered_para_props(&round_trip(&doc)); + + let stops = pp.tab_stops.as_ref().expect("tab_stops must survive"); + assert_eq!(stops.len(), 2, "both tab stops must survive"); + assert!((stops[0].position.value() - 36.0).abs() < 0.001); + assert_eq!(stops[0].alignment, TabAlignment::Left); + assert_eq!(stops[0].leader, TabLeader::None); + assert!((stops[1].position.value() - 144.5).abs() < 0.001); + assert_eq!(stops[1].alignment, TabAlignment::Decimal); + assert_eq!(stops[1].leader, TabLeader::Dot); +} + +// ── bridge_para_background_color_roundtrip ──────────────────────────────────── + +/// Paragraph `background_color` must survive a Loro CRDT round-trip (was +/// written as a Debug string the reader could not parse) — including non-Rgb +/// variants, which the codec must not collapse or drop. +#[test] +fn bridge_para_background_color_roundtrip() { + for color in [ + DocumentColor::from_hex("#ABCDEF").unwrap(), + DocumentColor::Cmyk(loki_primitives::color::CmykColor::new(0.1, 0.2, 0.3, 0.4)), + DocumentColor::Theme { + slot: ThemeColorSlot::Accent3, + tint: 0.25, + }, + DocumentColor::Transparent, + ] { + let mut para_props = ParaProps::default(); + para_props.background_color = Some(color.clone()); + + let doc = single_block_doc(styled_para_with_para(para_props)); + let pp = recovered_para_props(&round_trip(&doc)); + + assert_eq!( + pp.background_color, + Some(color), + "paragraph background_color must survive Loro round-trip" + ); + } +} + // ── bridge_para_fields_roundtrip ────────────────────────────────────────────── /// `ParaProps.page_break_before`, `orphan_control`, `outline_level`, and From 5791f66f5c2b5e8908acf6350ba7892068ca3ff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:49:49 +0000 Subject: [PATCH 04/27] feat(loro-bridge): native CRDT mappings for lists, quotes, divs, figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BulletList/OrderedList/BlockQuote/Div/Figure previously round-tripped only as opaque JSON snapshots (single blob, no concurrent merge) and their legacy read arms were debug-log stubs collapsing to HorizontalRule. They now get native mappings following the table.rs pattern (loro_bridge/containers.rs): rarely-changing structural metadata as a small JSON snapshot under container_meta, content in live nested movable block lists (list_items / child_blocks / caption_blocks) written through the shared block path — so paragraph text inside list items, quotes, divs, and figure captions lives in real LoroText containers and concurrent edits to different items merge. Legacy pre-opaque stubs (no content lists) still read as HorizontalRule — nothing recoverable. DefinitionList and inline fields/math stay on the opaque path. Round-trip + no-collapse regression tests in loro_bridge_container_tests.rs; audit §6 and plan Phase 1.3 updated. Phase 1.3 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 6 +- docs/deferred-features-plan-2026-07-04.md | 6 +- loki-doc-model/src/loro_bridge/containers.rs | 195 ++++++++++++++++++ loki-doc-model/src/loro_bridge/mod.rs | 1 + loki-doc-model/src/loro_bridge/opaque.rs | 10 +- loki-doc-model/src/loro_bridge/read.rs | 22 +- loki-doc-model/src/loro_bridge/write.rs | 17 +- loki-doc-model/src/loro_schema.rs | 20 ++ .../tests/loro_bridge_container_tests.rs | 122 +++++++++++ 9 files changed, 370 insertions(+), 29 deletions(-) create mode 100644 loki-doc-model/src/loro_bridge/containers.rs create mode 100644 loki-doc-model/tests/loro_bridge_container_tests.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 29477341..8d442f91 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -152,10 +152,10 @@ Still-open after verification (the DONE-SINCE ones moved to §1). | Item | Status | |---|---| | 300-line-ceiling backlog | **35** baselined files; CI-ratcheted so it can only shrink. `CLAUDE.md`'s worst-offenders sizes are stale (see S-9). | -| `tab_stops` Loro round-trip | STILL-OPEN — written as Debug string (`loro_bridge/write.rs:187`), no reader in `props_read.rs`. | -| paragraph `background_color` Loro round-trip | STILL-OPEN — Debug string (`write.rs:190`); the only reader (`props_read.rs:273`) uses `from_hex` and cannot parse it. | +| `tab_stops` Loro round-trip | ~~STILL-OPEN~~ **FIXED 2026-07-04** (plan Phase 1.1) — structured codec written + read back; `bridge_tab_stops_roundtrip`. | +| paragraph `background_color` Loro round-trip | ~~STILL-OPEN~~ **FIXED 2026-07-04** (plan Phase 1.2) — total `DocumentColor` codec (`loro_bridge/color_codec.rs`); `bridge_para_background_color_roundtrip`. | | `DocumentMeta`/DublinCore export | **DONE** (writes back to DOCX/ODT) — `CLAUDE.md` row is stale (see S-8). | -| CRDT bridge stubs | `BulletList`/`OrderedList`/`Figure`/`BlockQuote`/`Div` are debug-log-only in the bridge (export still works from the Document snapshot). | +| CRDT bridge stubs | ~~debug-log-only~~ **FIXED 2026-07-04** (plan Phase 1.3) — `BulletList`/`OrderedList`/`BlockQuote`/`Div`/`Figure` now have native mappings (`loro_bridge/containers.rs`, `table.rs` pattern: JSON metadata + live nested block lists); tested by `loro_bridge_container_tests.rs`. Legacy pre-opaque stubs still read as `HorizontalRule` (nothing recoverable); `DefinitionList` and inline fields/math stay on the opaque path. | --- diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 80544396..d0c201d0 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -43,9 +43,9 @@ closest thing to data loss in the suite. These are all code-confirmed in audit | Task | Source | Detail | Effort | |---|---|---|---| -| 1.1 | §6 | **`tab_stops` round-trip**: replace the Debug-string write (`loro_bridge/write.rs:187`) with a structured serialization (mirror the existing `props_read.rs` patterns) and add the missing reader. Add a round-trip test. | S–M | -| 1.2 | §6 | **Paragraph `background_color` round-trip**: same fix shape — structured write in `write.rs:190`, teach `props_read.rs:273` to decode it (today it calls `from_hex` on a Debug string and always fails). Round-trip test. | S | -| 1.3 | §6 | **CRDT bridge block stubs**: implement native bridge writes for `BulletList`/`OrderedList`/`Figure`/`BlockQuote`/`Div` (currently debug-log-only). The `Block::Table` write (`loro_bridge/write.rs:22`) is the worked example. Per-block round-trip tests. | M | +| 1.1 | §6 | ✅ **Done 2026-07-04** — structured tab-stop codec (`loro_bridge/decode.rs`) + reader; `bridge_tab_stops_roundtrip`. | S–M | +| 1.2 | §6 | ✅ **Done 2026-07-04** — total `DocumentColor` codec (`loro_bridge/color_codec.rs`, Rgb/Cmyk/Theme/Transparent) + reader; `bridge_para_background_color_roundtrip`. | S | +| 1.3 | §6 | ✅ **Done 2026-07-04** — native mappings for `BulletList`/`OrderedList`/`BlockQuote`/`Div`/`Figure` (`loro_bridge/containers.rs`, table.rs pattern); `loro_bridge_container_tests.rs`. `DefinitionList` + inline fields/math stay opaque. | M | | 1.4 | §2 | **`loro-bridge` tail**: non-Rgb colors (Theme/Cmyk), comment/bookmark anchors, quote/span attrs (`loro_bridge/inlines.rs:123,220`, `opaque.rs`, `table.rs:25`). Structural-table CRDT semantics can stay deferred (design work) but must keep its TODO. | M | | 1.5 | §2, §5 | **Loro oplog compaction** (`TODO(loro-compaction)`, memory Finding 6, Spec 06): compact at save/undo-horizon. `loki-bench/benches/leak_loro_history.rs` is the pre-built yardstick — use it as the acceptance test (history must stop growing linearly with edit count past the undo horizon). Server-side ADR-C013 `Compactor` already exists as prior art. | M | diff --git a/loki-doc-model/src/loro_bridge/containers.rs b/loki-doc-model/src/loro_bridge/containers.rs new file mode 100644 index 00000000..70c5eccd --- /dev/null +++ b/loki-doc-model/src/loro_bridge/containers.rs @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Native CRDT mappings for container blocks: bullet/ordered lists, block +//! quotes, divs, and figures. +//! +//! Follows the `table.rs` pattern: structural metadata that rarely changes is +//! stored as a small `serde`-JSON snapshot under [`KEY_CONTAINER_META`], while +//! the *content* — list items, quote/div children, figure caption and body — +//! lives in nested movable lists written through the shared block path +//! ([`super::write::map_blocks_to_list`]). Paragraph text inside a list item +//! therefore sits in real `LoroText` containers, so concurrent edits to +//! different items merge instead of conflicting on one opaque JSON blob. +//! +//! Layout per block type: +//! - `BulletList` → [`KEY_LIST_ITEMS`] (one nested block list per item) +//! - `OrderedList` → [`KEY_CONTAINER_META`] = `ListAttributes` JSON + +//! [`KEY_LIST_ITEMS`] +//! - `BlockQuote` → [`KEY_CHILD_BLOCKS`] +//! - `Div` → [`KEY_CONTAINER_META`] = `NodeAttr` JSON + +//! [`KEY_CHILD_BLOCKS`] +//! - `Figure` → [`KEY_CONTAINER_META`] = `(NodeAttr, short caption)` +//! JSON + [`KEY_CAPTION_BLOCKS`] + [`KEY_CHILD_BLOCKS`] +//! +//! On read, a block-type tag with no content lists is a legacy stub written +//! by a bridge version that predates both this mapping and the opaque +//! snapshot scheme; it carries nothing recoverable and falls back to +//! [`Block::HorizontalRule`], matching `table.rs`. Without the `serde` +//! feature there is no metadata format, so these blocks keep taking the +//! opaque path on write. + +#[cfg(feature = "serde")] +use super::BridgeError; +use crate::content::block::Block; +use crate::loro_schema::{ + BLOCK_TYPE_BLOCKQUOTE, BLOCK_TYPE_BULLET_LIST, BLOCK_TYPE_DIV, BLOCK_TYPE_FIGURE, + BLOCK_TYPE_ORDERED_LIST, KEY_CAPTION_BLOCKS, KEY_CHILD_BLOCKS, KEY_LIST_ITEMS, +}; +#[cfg(feature = "serde")] +use crate::loro_schema::{KEY_CONTAINER_META, KEY_TYPE}; +use loro::LoroMap; +#[cfg(feature = "serde")] +use loro::LoroMovableList; + +// ── Write path ──────────────────────────────────────────────────────────────── + +/// Writes a container block (`BulletList`/`OrderedList`/`BlockQuote`/`Div`/ +/// `Figure`) into `map` as its native block type. Callers guarantee `block` +/// is one of those variants. +#[cfg(feature = "serde")] +pub(super) fn write_container(block: &Block, map: &LoroMap) -> Result<(), BridgeError> { + match block { + Block::BulletList(items) => { + map.insert(KEY_TYPE, BLOCK_TYPE_BULLET_LIST)?; + write_items(items, map) + } + Block::OrderedList(attrs, items) => { + map.insert(KEY_TYPE, BLOCK_TYPE_ORDERED_LIST)?; + write_meta(attrs, map)?; + write_items(items, map) + } + Block::BlockQuote(children) => { + map.insert(KEY_TYPE, BLOCK_TYPE_BLOCKQUOTE)?; + write_blocks(children, map, KEY_CHILD_BLOCKS) + } + Block::Div(attr, children) => { + map.insert(KEY_TYPE, BLOCK_TYPE_DIV)?; + write_meta(attr, map)?; + write_blocks(children, map, KEY_CHILD_BLOCKS) + } + Block::Figure(attr, caption, content) => { + map.insert(KEY_TYPE, BLOCK_TYPE_FIGURE)?; + write_meta(&(attr, &caption.short), map)?; + write_blocks(&caption.full, map, KEY_CAPTION_BLOCKS)?; + write_blocks(content, map, KEY_CHILD_BLOCKS) + } + // Guarded by the caller's dispatch; preserve rather than lose. + other => super::opaque::write_opaque_block(other, map), + } +} + +#[cfg(feature = "serde")] +fn write_meta(meta: &T, map: &LoroMap) -> Result<(), BridgeError> { + match serde_json::to_string(meta) { + Ok(json) => { + map.insert(KEY_CONTAINER_META, json)?; + } + Err(err) => { + // Unreachable in practice: every model type derives Serialize. + tracing::warn!("loro bridge: failed to snapshot container meta: {err}"); + } + } + Ok(()) +} + +#[cfg(feature = "serde")] +fn write_items(items: &[Vec], map: &LoroMap) -> Result<(), BridgeError> { + let items_list = map.insert_container(KEY_LIST_ITEMS, LoroMovableList::new())?; + for (i, item_blocks) in items.iter().enumerate() { + let item_list = items_list.insert_container(i, LoroMovableList::new())?; + super::write::map_blocks_to_list(item_blocks, &item_list)?; + } + Ok(()) +} + +#[cfg(feature = "serde")] +fn write_blocks(blocks: &[Block], map: &LoroMap, key: &str) -> Result<(), BridgeError> { + let list = map.insert_container(key, LoroMovableList::new())?; + super::write::map_blocks_to_list(blocks, &list) +} + +// ── Read path ──────────────────────────────────────────────────────────────── + +/// Reads a native container block back into its [`Block`] variant. `block_type` +/// is the map's [`KEY_TYPE`] tag. Falls back to [`Block::HorizontalRule`] for +/// legacy stubs (no content lists) or unknown tags. +pub(super) fn read_container(block_type: &str, map: &LoroMap) -> Block { + let block = match block_type { + BLOCK_TYPE_BULLET_LIST => read_items(map).map(Block::BulletList), + BLOCK_TYPE_ORDERED_LIST => match (read_meta(map), read_items(map)) { + (Some(attrs), Some(items)) => Some(Block::OrderedList(attrs, items)), + _ => None, + }, + BLOCK_TYPE_BLOCKQUOTE => read_blocks(map, KEY_CHILD_BLOCKS).map(Block::BlockQuote), + BLOCK_TYPE_DIV => match (read_meta(map), read_blocks(map, KEY_CHILD_BLOCKS)) { + (Some(attr), Some(children)) => Some(Block::Div(attr, children)), + _ => None, + }, + BLOCK_TYPE_FIGURE => read_figure(map), + _ => None, + }; + block.unwrap_or_else(|| { + tracing::warn!("loro bridge: unreadable native {block_type} block; dropping to rule"); + Block::HorizontalRule + }) +} + +fn read_figure(map: &LoroMap) -> Option { + let (attr, short): ( + crate::content::attr::NodeAttr, + Option>, + ) = read_meta(map)?; + let full = read_blocks(map, KEY_CAPTION_BLOCKS)?; + let content = read_blocks(map, KEY_CHILD_BLOCKS)?; + Some(Block::Figure( + attr, + crate::content::block::Caption { short, full }, + content, + )) +} + +#[cfg(feature = "serde")] +fn read_meta(map: &LoroMap) -> Option { + let json = map + .get(KEY_CONTAINER_META) + .and_then(|v| v.into_value().ok()) + .and_then(|v| v.into_string().ok()) + .map(|s| s.to_string())?; + serde_json::from_str(&json).ok() +} + +#[cfg(not(feature = "serde"))] +fn read_meta(_map: &LoroMap) -> Option { + None +} + +fn read_items(map: &LoroMap) -> Option>> { + let items_list = map + .get(KEY_LIST_ITEMS)? + .into_container() + .ok()? + .into_movable_list() + .ok()?; + let mut items = Vec::with_capacity(items_list.len()); + for i in 0..items_list.len() { + let item_list = items_list + .get(i)? + .into_container() + .ok()? + .into_movable_list() + .ok()?; + items.push(super::read::reconstruct_blocks_from_list(&item_list)); + } + Some(items) +} + +fn read_blocks(map: &LoroMap, key: &str) -> Option> { + let list = map + .get(key)? + .into_container() + .ok()? + .into_movable_list() + .ok()?; + Some(super::read::reconstruct_blocks_from_list(&list)) +} diff --git a/loki-doc-model/src/loro_bridge/mod.rs b/loki-doc-model/src/loro_bridge/mod.rs index badecf1d..60a3136b 100644 --- a/loki-doc-model/src/loro_bridge/mod.rs +++ b/loki-doc-model/src/loro_bridge/mod.rs @@ -11,6 +11,7 @@ mod color_codec; mod comments; +mod containers; mod decode; mod incremental; #[cfg(feature = "serde")] diff --git a/loki-doc-model/src/loro_bridge/opaque.rs b/loki-doc-model/src/loro_bridge/opaque.rs index b19ef809..2c995c0c 100644 --- a/loki-doc-model/src/loro_bridge/opaque.rs +++ b/loki-doc-model/src/loro_bridge/opaque.rs @@ -4,10 +4,12 @@ //! Opaque-snapshot preservation for blocks the Loro schema cannot represent. //! //! The CRDT schema flattens paragraph content into a [`loro::LoroText`] with -//! marks, which cannot carry most structured content (lists, figures, footnote -//! bodies, fields, math; tables have their own native mapping in `table.rs`). -//! Before this module existed, such blocks were written as empty stubs and read -//! back as `Block::HorizontalRule` — i.e. a single edit cycle destroyed them. +//! marks, which cannot carry structured content the marks cannot express +//! (definition lists, fields, math, nested notes/images). Tables have a +//! native mapping in `table.rs`; lists, block quotes, divs, and figures have +//! theirs in `containers.rs`. Before this module existed, unsupported blocks +//! were written as empty stubs and read back as `Block::HorizontalRule` — +//! i.e. a single edit cycle destroyed them. //! //! Instead, any block that cannot round-trip through the text schema is //! serialized to JSON (via the model's `serde` derives) and stored verbatim diff --git a/loki-doc-model/src/loro_bridge/read.rs b/loki-doc-model/src/loro_bridge/read.rs index ea983977..b736a715 100644 --- a/loki-doc-model/src/loro_bridge/read.rs +++ b/loki-doc-model/src/loro_bridge/read.rs @@ -83,20 +83,14 @@ pub(super) fn map_loro_block(map: &LoroMap) -> Result { // stub written before this mapping has no skeleton and falls back to a // rule inside `read_table`. BLOCK_TYPE_TABLE => Ok(super::table::read_table(map)), - // Legacy stubs: blocks written by bridge versions that predate the - // opaque-snapshot scheme carry no content and cannot be recovered. - BLOCK_TYPE_BULLET_LIST => { - tracing::debug!("TODO/stub: loro bridge bullet list"); - Ok(Block::HorizontalRule) - } - BLOCK_TYPE_ORDERED_LIST => { - tracing::debug!("TODO/stub: loro bridge ordered list"); - Ok(Block::HorizontalRule) - } - BLOCK_TYPE_FIGURE => { - tracing::debug!("TODO/stub: loro bridge figure"); - Ok(Block::HorizontalRule) - } + // Native container mappings (metadata + live nested block lists). + // Legacy stubs with no content lists fall back to a rule inside + // `read_container`. + BLOCK_TYPE_BULLET_LIST + | BLOCK_TYPE_ORDERED_LIST + | BLOCK_TYPE_BLOCKQUOTE + | BLOCK_TYPE_DIV + | BLOCK_TYPE_FIGURE => Ok(super::containers::read_container(&block_type, map)), _ => { let inlines = reconstruct_inlines(map)?; if inlines.is_empty() { diff --git a/loki-doc-model/src/loro_bridge/write.rs b/loki-doc-model/src/loro_bridge/write.rs index 6852301a..f8b15170 100644 --- a/loki-doc-model/src/loro_bridge/write.rs +++ b/loki-doc-model/src/loro_bridge/write.rs @@ -18,12 +18,19 @@ use loro::{LoroMap, LoroMovableList, LoroText}; // ── Block serialization ─────────────────────────────────────────────────────── pub(crate) fn map_block(block: &Block, map: &LoroMap) -> Result<(), BridgeError> { - // Tables have a native mapping: a structural skeleton plus live per-cell - // block lists (see `table.rs`). Without `serde` there is no skeleton - // format, so the table takes the opaque path below instead. + // Tables and container blocks (lists, quotes, divs, figures) have native + // mappings: structural metadata plus live nested block lists (see + // `table.rs` / `containers.rs`). Without `serde` there is no metadata + // format, so they take the opaque path below instead. #[cfg(feature = "serde")] - if let Block::Table(table) = block { - return super::table::write_table(table, map); + match block { + Block::Table(table) => return super::table::write_table(table, map), + Block::BulletList(_) + | Block::OrderedList(_, _) + | Block::BlockQuote(_) + | Block::Div(_, _) + | Block::Figure(_, _, _) => return super::containers::write_container(block, map), + _ => {} } // Blocks (or paragraphs whose inline content) the flat text schema cannot // represent are preserved verbatim as opaque JSON snapshots so that a diff --git a/loki-doc-model/src/loro_schema.rs b/loki-doc-model/src/loro_schema.rs index a763ac22..4e299c60 100644 --- a/loki-doc-model/src/loro_schema.rs +++ b/loki-doc-model/src/loro_schema.rs @@ -81,6 +81,7 @@ pub const BLOCK_TYPE_FIGURE: &str = "figure"; pub const BLOCK_TYPE_CODE_BLOCK: &str = "code_block"; pub const BLOCK_TYPE_HR: &str = "hr"; pub const BLOCK_TYPE_BLOCKQUOTE: &str = "blockquote"; +pub const BLOCK_TYPE_DIV: &str = "div"; pub const BLOCK_TYPE_STYLED_PARA: &str = "styled_para"; /// Block preserved as an opaque JSON snapshot (see `loro_bridge::opaque`). /// Used for block types without a native CRDT mapping so that round-trips @@ -106,6 +107,25 @@ pub const KEY_TABLE_SKELETON: &str = "table_skeleton"; /// read by the same traversal order. pub const KEY_TABLE_CELLS: &str = "table_cells"; +/// Key for the JSON structural metadata of a native container block (see +/// `loro_bridge::containers`): the [`ListAttributes`] of a +/// [`BLOCK_TYPE_ORDERED_LIST`], the `NodeAttr` of a [`BLOCK_TYPE_DIV`], or the +/// `(NodeAttr, short caption)` pair of a [`BLOCK_TYPE_FIGURE`]. +/// [`BLOCK_TYPE_BULLET_LIST`] and [`BLOCK_TYPE_BLOCKQUOTE`] carry no metadata. +pub const KEY_CONTAINER_META: &str = "container_meta"; + +/// Key for the live item contents of a native list block — a movable list with +/// one entry per list item, each entry itself a movable list of that item's +/// blocks (written via the shared block path, like [`KEY_TABLE_CELLS`]). +pub const KEY_LIST_ITEMS: &str = "list_items"; + +/// Key for the live child blocks of a native container block (block-quote / +/// div children, figure content) — a movable list of blocks. +pub const KEY_CHILD_BLOCKS: &str = "child_blocks"; + +/// Key for the live caption body blocks of a native [`BLOCK_TYPE_FIGURE`]. +pub const KEY_CAPTION_BLOCKS: &str = "caption_blocks"; + // ----------------------------------------------------------------------------- // CharProps Mark Keys // ----------------------------------------------------------------------------- diff --git a/loki-doc-model/tests/loro_bridge_container_tests.rs b/loki-doc-model/tests/loro_bridge_container_tests.rs new file mode 100644 index 00000000..a39dce9e --- /dev/null +++ b/loki-doc-model/tests/loro_bridge_container_tests.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Loro bridge round-trip tests for the native container-block mappings +//! (`loro_bridge/containers.rs`): bullet/ordered lists, block quotes, divs, +//! and figures must survive a document_to_loro → loro_to_document cycle as +//! their own variants — not as opaque snapshots and not as the pre-mapping +//! `HorizontalRule` stubs. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::{ + Block, Caption, ListAttributes, ListDelimiter, ListNumberStyle, +}; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document}; + +fn round_trip_block(block: Block) -> Block { + let mut doc = Document::new(); + doc.sections[0].blocks.push(block); + let loro = document_to_loro(&doc).expect("document_to_loro must succeed"); + let recovered = loro_to_document(&loro).expect("loro_to_document must succeed"); + recovered.sections[0] + .blocks + .first() + .expect("block must survive round-trip") + .clone() +} + +fn para(text: &str) -> Block { + Block::Para(vec![Inline::Str(text.into())]) +} + +// ── Lists ───────────────────────────────────────────────────────────────────── + +#[test] +fn bullet_list_roundtrips_natively() { + let list = Block::BulletList(vec![ + vec![para("first item")], + vec![para("second item"), para("second item, second para")], + vec![], // empty item must survive as an item, not vanish + ]); + assert_eq!(round_trip_block(list.clone()), list); +} + +#[test] +fn ordered_list_roundtrips_attributes_and_items() { + let list = Block::OrderedList( + ListAttributes { + start_number: 7, + style: ListNumberStyle::LowerRoman, + delimiter: ListDelimiter::TwoParens, + }, + vec![vec![para("vii")], vec![para("viii")]], + ); + assert_eq!(round_trip_block(list.clone()), list); +} + +#[test] +fn nested_bullet_list_roundtrips() { + let inner = Block::BulletList(vec![vec![para("inner")]]); + let outer = Block::BulletList(vec![vec![para("outer"), inner]]); + assert_eq!(round_trip_block(outer.clone()), outer); +} + +// ── Block quote / div ───────────────────────────────────────────────────────── + +#[test] +fn block_quote_roundtrips_natively() { + let quote = Block::BlockQuote(vec![para("quoted"), para("still quoted")]); + assert_eq!(round_trip_block(quote.clone()), quote); +} + +#[test] +fn div_roundtrips_attr_and_children() { + let mut attr = NodeAttr::default(); + attr.id = Some("sidebar-1".into()); + attr.classes.push("sidebar".into()); + attr.kv.push(("role".into(), "note".into())); + let div = Block::Div(attr, vec![para("div body")]); + assert_eq!(round_trip_block(div.clone()), div); +} + +// ── Figure ──────────────────────────────────────────────────────────────────── + +#[test] +fn figure_roundtrips_caption_and_content() { + let mut attr = NodeAttr::default(); + attr.id = Some("fig-1".into()); + let figure = Block::Figure( + attr, + Caption { + short: Some(vec![Inline::Str("Short".into())]), + full: vec![para("Full caption body")], + }, + vec![para("figure content")], + ); + assert_eq!(round_trip_block(figure.clone()), figure); +} + +// ── Regression: the old failure mode ────────────────────────────────────────── + +/// The pre-mapping bridge collapsed these types to `HorizontalRule` after one +/// CRDT cycle. Guard the whole family against reintroducing that. +#[test] +fn no_container_collapses_to_horizontal_rule() { + let blocks = vec![ + Block::BulletList(vec![vec![para("x")]]), + Block::OrderedList(ListAttributes::default(), vec![vec![para("y")]]), + Block::BlockQuote(vec![para("z")]), + Block::Div(NodeAttr::default(), vec![para("w")]), + Block::Figure(NodeAttr::default(), Caption::default(), vec![para("v")]), + ]; + for block in blocks { + let recovered = round_trip_block(block.clone()); + assert!( + !matches!(recovered, Block::HorizontalRule), + "{block:?} must not collapse to HorizontalRule" + ); + assert_eq!(recovered, block); + } +} From efbd87dd071d074df538cbaaa3480f8ef3af1647 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 23:58:42 +0000 Subject: [PATCH 05/27] feat(loro-bridge): carry non-Rgb colors, comment/bookmark anchors, quote/span attrs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes three inline-level round-trip gaps (deferred-features audit §2, loro-bridge topic; plan Phase 1.4): - Character colors now use the total DocumentColor codec on both the text-mark path (MARK_COLOR) and the block-map direct-char-props path, so Theme/Cmyk/Transparent survive instead of being dropped for lack of a hex form. Old hex strings still decode. - Comment range markers and bookmark start/end markers are preserved as OBJECT_REPLACEMENT_CHAR anchors with MARK_COMMENT/MARK_BOOKMARK snapshot marks (same mechanism as images), staying positioned between text runs; previously silently dropped by text flattening. - Inline::Quoted keeps its quote type (MARK_QUOTE_TYPE) and Inline::Span its NodeAttr (MARK_SPAN_ATTR) as range marks; both write children recursively so nested marks survive too. - loro_schema.rs crossed the 300-line ceiling and was split into loro_schema/{mod,marks}.rs (pub-use keeps all paths working). - roundtrip_transparent_color_graceful_drop is now roundtrip_transparent_color_survives — the drop was the workaround, not the contract. Still deferred with TODO(loro-bridge): non-Rgb border colors (the colon-delimited border format cannot carry the codec strings), Cite metadata, structural-table CRDT semantics. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 2 +- loki-doc-model/src/loro_bridge/decode.rs | 4 + loki-doc-model/src/loro_bridge/inlines.rs | 53 +++++- .../src/loro_bridge/inlines_read.rs | 95 +++++++++-- loki-doc-model/src/loro_bridge/opaque.rs | 10 +- loki-doc-model/src/loro_bridge/props_read.rs | 5 +- loki-doc-model/src/loro_bridge/write.rs | 8 +- loki-doc-model/src/loro_schema/marks.rs | 122 ++++++++++++++ .../{loro_schema.rs => loro_schema/mod.rs} | 103 +----------- .../tests/loro_bridge_inline_tail_tests.rs | 159 ++++++++++++++++++ loki-doc-model/tests/loro_bridge_tests.rs | 13 +- 12 files changed, 434 insertions(+), 142 deletions(-) create mode 100644 loki-doc-model/src/loro_schema/marks.rs rename loki-doc-model/src/{loro_schema.rs => loro_schema/mod.rs} (67%) create mode 100644 loki-doc-model/tests/loro_bridge_inline_tail_tests.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 8d442f91..88c83562 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -43,7 +43,7 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | Topic | file:line(s) | Defers what | |---|---|---| | `3b-3` (partial) | `navigation.rs` (many) | Cross-page nav: up/down works; **left/right at page edges + `page_index` recompute after split/merge still `None`** | -| `loro-bridge` | `loro_bridge/inlines.rs:123,220`, `opaque.rs`, `table.rs:25` | Non-Rgb colors (Theme/Cmyk), comment/bookmark anchors, quote/span attrs, structural-table CRDT semantics | +| `loro-bridge` | `loro_bridge/decode.rs` (borders), `table.rs:25` | ~~Non-Rgb colors, comment/bookmark anchors, quote/span attrs~~ **fixed 2026-07-04** (plan Phase 1.4). Remaining: non-Rgb *border* colors (format migration), `Cite` metadata, structural-table CRDT semantics | | `loro-compaction` | `loki-bench/benches/leak_loro_history.rs`; bridge | Compact the CRDT oplog at save/undo-horizon (memory Finding 6) | | `omml` | `docx/omml/mod.rs:20` | OMML↔MathML for delimiters, n-ary, matrices, accents | | `link-click` | `resolve.rs:689`, `items.rs:125`, `para.rs:203`, `scene.rs:519` | Interactive hyperlink hit-testing (only a visual hint today) | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index d0c201d0..82be2383 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -46,7 +46,7 @@ closest thing to data loss in the suite. These are all code-confirmed in audit | 1.1 | §6 | ✅ **Done 2026-07-04** — structured tab-stop codec (`loro_bridge/decode.rs`) + reader; `bridge_tab_stops_roundtrip`. | S–M | | 1.2 | §6 | ✅ **Done 2026-07-04** — total `DocumentColor` codec (`loro_bridge/color_codec.rs`, Rgb/Cmyk/Theme/Transparent) + reader; `bridge_para_background_color_roundtrip`. | S | | 1.3 | §6 | ✅ **Done 2026-07-04** — native mappings for `BulletList`/`OrderedList`/`BlockQuote`/`Div`/`Figure` (`loro_bridge/containers.rs`, table.rs pattern); `loro_bridge_container_tests.rs`. `DefinitionList` + inline fields/math stay opaque. | M | -| 1.4 | §2 | **`loro-bridge` tail**: non-Rgb colors (Theme/Cmyk), comment/bookmark anchors, quote/span attrs (`loro_bridge/inlines.rs:123,220`, `opaque.rs`, `table.rs:25`). Structural-table CRDT semantics can stay deferred (design work) but must keep its TODO. | M | +| 1.4 | §2 | ✅ **Done 2026-07-04** — char colors use the total `DocumentColor` codec (Theme/Cmyk/Transparent survive, mark + block-map paths); comment/bookmark anchors preserved as `OBJECT_REPLACEMENT_CHAR` snapshot marks; `Quoted` quote-type and `Span` attrs carried as range marks with recursive child writes; `loro_bridge_inline_tail_tests.rs`. Remaining TODO(loro-bridge): non-Rgb *border* colors (colon-format migration), `Cite` metadata, structural-table CRDT semantics. | M | | 1.5 | §2, §5 | **Loro oplog compaction** (`TODO(loro-compaction)`, memory Finding 6, Spec 06): compact at save/undo-horizon. `loki-bench/benches/leak_loro_history.rs` is the pre-built yardstick — use it as the acceptance test (history must stop growing linearly with edit count past the undo horizon). Server-side ADR-C013 `Compactor` already exists as prior art. | M | **Exit criteria**: `metadata_round_trip.rs`-style tests pass for 1.1–1.3; the diff --git a/loki-doc-model/src/loro_bridge/decode.rs b/loki-doc-model/src/loro_bridge/decode.rs index 8bfdd235..7e1d19af 100644 --- a/loki-doc-model/src/loro_bridge/decode.rs +++ b/loki-doc-model/src/loro_bridge/decode.rs @@ -209,6 +209,10 @@ pub(super) fn decode_tab_stops(s: &str) -> Option> { // ── Border codec ───────────────────────────────────────────────────────────── /// Encode a [`Border`] as a compact `"Style:width_pt:color:spacing_pt"` string. +/// +/// TODO(loro-bridge): non-Rgb border colors (Theme/Cmyk) collapse to `auto` +/// here — the colon-delimited format cannot carry the `color_codec` encodings +/// (they contain `:`), so lifting this needs a format migration. pub(super) fn encode_border(b: &Border) -> String { let style = match b.style { BorderStyle::None => "None", diff --git a/loki-doc-model/src/loro_bridge/inlines.rs b/loki-doc-model/src/loro_bridge/inlines.rs index 2369158f..ceb15ee5 100644 --- a/loki-doc-model/src/loro_bridge/inlines.rs +++ b/loki-doc-model/src/loro_bridge/inlines.rs @@ -7,7 +7,8 @@ //! stay under the 300-line ceiling. use super::BridgeError; -use crate::content::inline::Inline; +use super::color_codec::encode_document_color; +use crate::content::inline::{Inline, QuoteType}; use crate::loro_schema::*; use crate::style::props::char_props::CharProps; use loro::{LoroMap, LoroText}; @@ -105,6 +106,35 @@ pub(super) fn map_inlines( text.mark(start..end, MARK_LINK_URL, target.url.as_str())?; } } + // Quote/span wrappers write their children recursively (so nested + // marks survive) and then lay their own range mark on top. + Inline::Quoted(quote_type, inner) => { + let start = text.len_unicode(); + map_inlines(inner, text, block_map)?; + let end = text.len_unicode(); + if start < end { + let value = match quote_type { + QuoteType::SingleQuote => "Single", + QuoteType::DoubleQuote => "Double", + }; + text.mark(start..end, MARK_QUOTE_TYPE, value)?; + } + } + #[cfg(feature = "serde")] + Inline::Span(attr, inner) => { + let start = text.len_unicode(); + map_inlines(inner, text, block_map)?; + let end = text.len_unicode(); + if start < end { + match serde_json::to_string(attr) { + Ok(json) => text.mark(start..end, MARK_SPAN_ATTR, json)?, + Err(err) => { + // Unreachable in practice: NodeAttr derives Serialize. + tracing::warn!("loro bridge: failed to encode span attr: {err}"); + } + } + } + } // Inline objects anchored natively (placeholder char + data mark) so // they stay live, positioned, deletable inlines. Only *top-level* // objects reach here — one nested in a wrapper keeps its block @@ -118,9 +148,19 @@ pub(super) fn map_inlines( Inline::Note(kind, body) => { super::inline_objects::write_note(kind, body, text, block_map)?; } + // Comment/bookmark range markers carry no text; anchor them like + // images so they survive round-trips as positioned inlines. + #[cfg(feature = "serde")] + Inline::Comment(_) => { + super::inline_objects::write_inline_object(inline, text, MARK_COMMENT)?; + } + #[cfg(feature = "serde")] + Inline::Bookmark(_, _) => { + super::inline_objects::write_inline_object(inline, text, MARK_BOOKMARK)?; + } // Text-bearing wrappers without a dedicated mark: keep the text. - // Quote type / span attrs / citation metadata are not yet carried - // through the CRDT — TODO(loro-bridge). + // Citation metadata is not yet carried through the CRDT — + // TODO(loro-bridge). _ => { let text_str = extract_plain_text(std::slice::from_ref(inline)); if !text_str.is_empty() { @@ -217,11 +257,8 @@ pub(super) fn apply_char_props_marks( if let Some(v) = &props.vertical_align { text.mark(start..end, MARK_VERTICAL_ALIGN, format!("{:?}", v))?; } - // Non-Rgb variants (Theme, Cmyk, Transparent) have no hex repr and are deferred — TODO(loro-bridge) - if let Some(v) = &props.color - && let Some(hex) = v.to_hex() - { - text.mark(start..end, MARK_COLOR, hex)?; + if let Some(v) = &props.color { + text.mark(start..end, MARK_COLOR, encode_document_color(v))?; } if let Some(v) = &props.highlight_color { text.mark(start..end, MARK_HIGHLIGHT_COLOR, format!("{v:?}"))?; diff --git a/loki-doc-model/src/loro_bridge/inlines_read.rs b/loki-doc-model/src/loro_bridge/inlines_read.rs index ac688476..fef13204 100644 --- a/loki-doc-model/src/loro_bridge/inlines_read.rs +++ b/loki-doc-model/src/loro_bridge/inlines_read.rs @@ -7,15 +7,15 @@ //! under the 300-line ceiling. use super::BridgeError; +use super::color_codec::decode_document_color; use super::decode::{ decode_highlight_color, decode_strikethrough, decode_underline, decode_vertical_align, }; use crate::content::attr::NodeAttr; -use crate::content::inline::{Inline, StyledRun}; +use crate::content::inline::{Inline, QuoteType, StyledRun}; use crate::loro_schema::*; use crate::style::catalog::StyleId; use crate::style::props::char_props::CharProps; -use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; use loro::LoroValue; @@ -55,19 +55,40 @@ pub(super) fn reconstruct_inlines(map: &loro::LoroMap) -> Result, Br inlines.push(note); continue; } + if let Some(anchor) = decode_snapshot_anchor(&attrs, MARK_COMMENT, |i| { + matches!(i, Inline::Comment(_)) + }) { + inlines.push(anchor); + continue; + } + if let Some(anchor) = decode_snapshot_anchor(&attrs, MARK_BOOKMARK, |i| { + matches!(i, Inline::Bookmark(_, _)) + }) { + inlines.push(anchor); + continue; + } let props = read_char_props_from_marks(&attrs); let style_id = read_style_id_from_marks(&attrs); - if props.is_some() || style_id.is_some() { - let run = StyledRun { + let mut inline = if props.is_some() || style_id.is_some() { + Inline::StyledRun(StyledRun { style_id, direct_props: props.map(Box::new), content: vec![Inline::Str(insert.to_string())], attr: NodeAttr::default(), - }; - inlines.push(Inline::StyledRun(run)); + }) } else { - inlines.push(Inline::Str(insert.to_string())); + Inline::Str(insert.to_string()) + }; + // Re-wrap span/quote range marks (innermost first, so a + // quoted span reads back as Quoted(Span(..)) — the write + // path flattens both onto the same range). + if let Some(attr) = decode_span_attr(&attrs) { + inline = Inline::Span(attr, vec![inline]); + } + if let Some(qt) = decode_quote_type(&attrs) { + inline = Inline::Quoted(qt, vec![inline]); } + inlines.push(inline); } } } @@ -75,26 +96,47 @@ pub(super) fn reconstruct_inlines(map: &loro::LoroMap) -> Result, Br Ok(inlines) } -/// Reconstructs an [`Inline::Image`] from a [`MARK_IMAGE`] anchor's `serde`-JSON -/// snapshot, if present. Returns `None` for ordinary formatted text. +/// Reconstructs an inline from a snapshot-anchor mark (`mark_key` holding a +/// `serde`-JSON `Inline`), if present and of the expected variant (`want`). +/// Returns `None` for ordinary formatted text. #[cfg(feature = "serde")] -fn decode_image(attrs: &rustc_hash::FxHashMap) -> Option { - let Some(LoroValue::String(json)) = attrs.get(MARK_IMAGE) else { +fn decode_snapshot_anchor( + attrs: &rustc_hash::FxHashMap, + mark_key: &str, + want: fn(&Inline) -> bool, +) -> Option { + let Some(LoroValue::String(json)) = attrs.get(mark_key) else { return None; }; match serde_json::from_str::(json) { - Ok(inline @ Inline::Image(..)) => Some(inline), + Ok(inline) if want(&inline) => Some(inline), Ok(_) => { - tracing::warn!("loro bridge: MARK_IMAGE snapshot was not an image"); + tracing::warn!("loro bridge: {mark_key} snapshot was the wrong inline variant"); None } Err(err) => { - tracing::warn!("loro bridge: failed to decode inline image: {err}"); + tracing::warn!("loro bridge: failed to decode {mark_key} anchor: {err}"); None } } } +#[cfg(not(feature = "serde"))] +fn decode_snapshot_anchor( + _attrs: &rustc_hash::FxHashMap, + _mark_key: &str, + _want: fn(&Inline) -> bool, +) -> Option { + None +} + +/// Reconstructs an [`Inline::Image`] from a [`MARK_IMAGE`] anchor's `serde`-JSON +/// snapshot, if present. Returns `None` for ordinary formatted text. +#[cfg(feature = "serde")] +fn decode_image(attrs: &rustc_hash::FxHashMap) -> Option { + decode_snapshot_anchor(attrs, MARK_IMAGE, |i| matches!(i, Inline::Image(..))) +} + /// Reconstructs an [`Inline::Note`] from a [`MARK_NOTE`] anchor: its `(kind, /// idx)` mark plus the body fetched from `notes` at `idx` (a live container). #[cfg(feature = "serde")] @@ -129,6 +171,29 @@ fn decode_note( None } +/// Reads the quote style carried by [`MARK_QUOTE_TYPE`]. +fn decode_quote_type(attrs: &rustc_hash::FxHashMap) -> Option { + match attrs.get(MARK_QUOTE_TYPE) { + Some(LoroValue::String(s)) if s.as_str() == "Single" => Some(QuoteType::SingleQuote), + Some(LoroValue::String(s)) if s.as_str() == "Double" => Some(QuoteType::DoubleQuote), + _ => None, + } +} + +/// Reads the span attributes carried by [`MARK_SPAN_ATTR`]. +#[cfg(feature = "serde")] +fn decode_span_attr(attrs: &rustc_hash::FxHashMap) -> Option { + let Some(LoroValue::String(json)) = attrs.get(MARK_SPAN_ATTR) else { + return None; + }; + serde_json::from_str(json).ok() +} + +#[cfg(not(feature = "serde"))] +fn decode_span_attr(_attrs: &rustc_hash::FxHashMap) -> Option { + None +} + /// Reads the named character style carried by [`MARK_CHAR_STYLE_ID`]. fn read_style_id_from_marks(attrs: &rustc_hash::FxHashMap) -> Option { if let Some(LoroValue::String(s)) = attrs.get(MARK_CHAR_STYLE_ID) { @@ -196,7 +261,7 @@ fn read_char_props_from_marks( read_str!(underline, MARK_UNDERLINE, decode_underline); read_str!(strikethrough, MARK_STRIKETHROUGH, decode_strikethrough); read_str!(vertical_align, MARK_VERTICAL_ALIGN, decode_vertical_align); - read_str!(color, MARK_COLOR, |s: &str| DocumentColor::from_hex(s).ok()); + read_str!(color, MARK_COLOR, decode_document_color); read_str!( highlight_color, MARK_HIGHLIGHT_COLOR, diff --git a/loki-doc-model/src/loro_bridge/opaque.rs b/loki-doc-model/src/loro_bridge/opaque.rs index 2c995c0c..dd3c9ebe 100644 --- a/loki-doc-model/src/loro_bridge/opaque.rs +++ b/loki-doc-model/src/loro_bridge/opaque.rs @@ -85,10 +85,12 @@ fn inline_round_trips_nested(inline: &Inline) -> bool { | Inline::Span(_, inner) | Inline::Link(_, inner, _) => inner.iter().all(inline_round_trips_nested), Inline::StyledRun(run) => run.content.iter().all(inline_round_trips_nested), - // Comment and bookmark anchors carry no body text and have always - // been dropped by the text flattening; treating them as representable - // keeps the vast majority of paragraphs editable. - // TODO(loro-bridge): preserve comment/bookmark anchors as marks. + // Comment and bookmark anchors carry no body text. A *top-level* + // anchor is now preserved natively (an OBJECT_REPLACEMENT_CHAR anchor + // with a MARK_COMMENT/MARK_BOOKMARK snapshot mark — see + // `inlines::map_inlines`); one nested inside a wrapper is still + // dropped by the text flattening, which loses no text and keeps the + // vast majority of paragraphs editable. Inline::Comment(_) | Inline::Bookmark(_, _) => true, // Nested Note/Image, Field, Math, RawInline, Cite — structured content // the flat text cannot carry (and, when nested, the native anchor path diff --git a/loki-doc-model/src/loro_bridge/props_read.rs b/loki-doc-model/src/loro_bridge/props_read.rs index 9d803612..664a5e27 100644 --- a/loki-doc-model/src/loro_bridge/props_read.rs +++ b/loki-doc-model/src/loro_bridge/props_read.rs @@ -16,7 +16,6 @@ use crate::meta::language::LanguageTag; use crate::style::list_style::ListId; use crate::style::props::char_props::CharProps; use crate::style::props::para_props::ParaProps; -use loki_primitives::color::DocumentColor; use loki_primitives::units::Points; use loro::LoroMap; @@ -253,13 +252,13 @@ pub(super) fn reconstruct_char_props_from_map(block_map: &LoroMap) -> Option Result< if let Some(v) = &props.hyperlink { map.insert("hyperlink", v.as_str())?; } - if let Some(hex) = props.color.as_ref().and_then(|c| c.to_hex()) { - map.insert("color", hex)?; + if let Some(v) = &props.color { + map.insert("color", encode_document_color(v))?; } - if let Some(hex) = props.background_color.as_ref().and_then(|c| c.to_hex()) { - map.insert("background_color", hex)?; + if let Some(v) = &props.background_color { + map.insert("background_color", encode_document_color(v))?; } if let Some(v) = &props.highlight_color { map.insert("highlight_color", format!("{v:?}"))?; diff --git a/loki-doc-model/src/loro_schema/marks.rs b/loki-doc-model/src/loro_schema/marks.rs new file mode 100644 index 00000000..a22ff745 --- /dev/null +++ b/loki-doc-model/src/loro_schema/marks.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Text-formatting mark keys and inline-object anchor constants. +//! +//! Split from the parent `loro_schema` module to keep files under the +//! 300-line ceiling. + +// ----------------------------------------------------------------------------- +// CharProps Mark Keys +// ----------------------------------------------------------------------------- + +pub const MARK_BOLD: &str = "bold"; +pub const MARK_ITALIC: &str = "italic"; +pub const MARK_UNDERLINE: &str = "underline"; +pub const MARK_STRIKETHROUGH: &str = "strikethrough"; +pub const MARK_COLOR: &str = "color"; +pub const MARK_HIGHLIGHT_COLOR: &str = "highlight_color"; +pub const MARK_FONT_FAMILY: &str = "font_family"; +pub const MARK_FONT_SIZE_PT: &str = "font_size_pt"; +pub const MARK_VERTICAL_ALIGN: &str = "vertical_align"; +pub const MARK_LINK_URL: &str = "link_url"; +pub const MARK_LANGUAGE: &str = "language"; +pub const MARK_LANGUAGE_COMPLEX: &str = "language_complex"; +pub const MARK_LANGUAGE_EAST_ASIAN: &str = "language_east_asian"; +pub const MARK_LETTER_SPACING: &str = "letter_spacing"; +pub const MARK_WORD_SPACING: &str = "word_spacing"; +pub const MARK_SCALE: &str = "scale"; +pub const MARK_SMALL_CAPS: &str = "small_caps"; +pub const MARK_ALL_CAPS: &str = "all_caps"; +pub const MARK_SHADOW: &str = "shadow"; +pub const MARK_KERNING: &str = "kerning"; +/// Named character style (`StyledRun::style_id`) carried as a mark so that +/// run-level style references survive Loro round-trips. +pub const MARK_CHAR_STYLE_ID: &str = "char_style_id"; +pub const MARK_OUTLINE: &str = "outline"; +/// `Inline::Quoted`'s quote style over the quoted range: "Single" | "Double". +pub const MARK_QUOTE_TYPE: &str = "quote_type"; +/// `Inline::Span`'s `NodeAttr` as a `serde`-JSON snapshot over the span range. +pub const MARK_SPAN_ATTR: &str = "span_attr"; + +// ----------------------------------------------------------------------------- +// Inline objects (anchored by a placeholder char + a data-bearing mark) +// ----------------------------------------------------------------------------- + +/// Object-replacement character (U+FFFC) — the in-text anchor for an inline +/// object whose structured data is carried by a mark over the single anchor +/// position. The anchor occupies one Unicode scalar so the object is a +/// discrete, positioned, deletable element in the flat text stream. +pub const OBJECT_REPLACEMENT_CHAR: char = '\u{FFFC}'; + +/// String form of [`OBJECT_REPLACEMENT_CHAR`] for `LoroText::insert`. +pub const OBJECT_REPLACEMENT_STR: &str = "\u{FFFC}"; + +/// Inline image data: a `serde`-JSON snapshot of the `Inline::Image`, carried +/// as a mark over a single [`OBJECT_REPLACEMENT_CHAR`] anchor so that +/// image-bearing paragraphs round-trip *natively* (the image is a live, +/// positioned inline object) instead of as opaque block snapshots. +pub const MARK_IMAGE: &str = "image"; + +/// Inline footnote/endnote reference: carried as a mark over a single +/// [`OBJECT_REPLACEMENT_CHAR`] anchor. The value is a `serde`-JSON +/// `(NoteKind, usize)` pair — the note's kind and the index of its **body** in +/// the block's [`KEY_NOTES`] container. The `usize` index also makes each note's +/// mark unique, so adjacent notes do not merge into one rich-text delta span. +/// +/// The body itself is a **live CRDT container** (not a JSON blob in the mark), +/// so footnote text is editable and mergeable like a table cell's. +pub const MARK_NOTE: &str = "note"; + +/// Key (within a paragraph-like block's map) for the block's note bodies — a +/// movable list with one entry per [`MARK_NOTE`] anchor in the block, each a +/// movable list of the note body's blocks (written via the shared block path). +/// Indexed by the `usize` carried in each anchor's [`MARK_NOTE`] mark. +pub const KEY_NOTES: &str = "notes"; + +/// Comment anchor (`Inline::Comment`): the whole inline as a `serde`-JSON +/// snapshot carried as a mark over a single [`OBJECT_REPLACEMENT_CHAR`], so +/// comment range markers survive CRDT round-trips as positioned anchors. +pub const MARK_COMMENT: &str = "comment"; + +/// Bookmark marker (`Inline::Bookmark`): the whole inline as a `serde`-JSON +/// snapshot carried as a mark over a single [`OBJECT_REPLACEMENT_CHAR`]. +pub const MARK_BOOKMARK: &str = "bookmark"; + +/// Every inline-object anchor mark key (carried over a single +/// [`OBJECT_REPLACEMENT_CHAR`]). Registered with non-expanding behaviour and +/// shared by the write/read paths. Keep in sync as new inline objects migrate +/// off the opaque-snapshot fallback. +pub const INLINE_OBJECT_MARK_KEYS: &[&str] = &[MARK_IMAGE, MARK_NOTE, MARK_COMMENT, MARK_BOOKMARK]; + +/// Every character-level mark key (formatting that lives on a text range). +/// +/// Single source of truth shared by `document_to_loro` (which registers each +/// key's `expand` behaviour) and `replace_text` (which resets an inserted +/// range's full formatting). Keep this in sync with the read/write paths. +pub const CHAR_MARK_KEYS: &[&str] = &[ + MARK_BOLD, + MARK_ITALIC, + MARK_UNDERLINE, + MARK_STRIKETHROUGH, + MARK_COLOR, + MARK_HIGHLIGHT_COLOR, + MARK_FONT_FAMILY, + MARK_FONT_SIZE_PT, + MARK_VERTICAL_ALIGN, + MARK_LINK_URL, + MARK_LANGUAGE, + MARK_LANGUAGE_COMPLEX, + MARK_LANGUAGE_EAST_ASIAN, + MARK_LETTER_SPACING, + MARK_WORD_SPACING, + MARK_SCALE, + MARK_SMALL_CAPS, + MARK_ALL_CAPS, + MARK_SHADOW, + MARK_KERNING, + MARK_OUTLINE, + MARK_CHAR_STYLE_ID, + MARK_QUOTE_TYPE, + MARK_SPAN_ATTR, +]; diff --git a/loki-doc-model/src/loro_schema.rs b/loki-doc-model/src/loro_schema/mod.rs similarity index 67% rename from loki-doc-model/src/loro_schema.rs rename to loki-doc-model/src/loro_schema/mod.rs index 4e299c60..64bb6f86 100644 --- a/loki-doc-model/src/loro_schema.rs +++ b/loki-doc-model/src/loro_schema/mod.rs @@ -3,6 +3,9 @@ //! Schema constants for mapping Loki documents to Loro CRDT structures. +mod marks; +pub use marks::*; + /// Key for the Document metadata map. pub const KEY_METADATA: &str = "metadata"; @@ -126,106 +129,6 @@ pub const KEY_CHILD_BLOCKS: &str = "child_blocks"; /// Key for the live caption body blocks of a native [`BLOCK_TYPE_FIGURE`]. pub const KEY_CAPTION_BLOCKS: &str = "caption_blocks"; -// ----------------------------------------------------------------------------- -// CharProps Mark Keys -// ----------------------------------------------------------------------------- - -pub const MARK_BOLD: &str = "bold"; -pub const MARK_ITALIC: &str = "italic"; -pub const MARK_UNDERLINE: &str = "underline"; -pub const MARK_STRIKETHROUGH: &str = "strikethrough"; -pub const MARK_COLOR: &str = "color"; -pub const MARK_HIGHLIGHT_COLOR: &str = "highlight_color"; -pub const MARK_FONT_FAMILY: &str = "font_family"; -pub const MARK_FONT_SIZE_PT: &str = "font_size_pt"; -pub const MARK_VERTICAL_ALIGN: &str = "vertical_align"; -pub const MARK_LINK_URL: &str = "link_url"; -pub const MARK_LANGUAGE: &str = "language"; -pub const MARK_LANGUAGE_COMPLEX: &str = "language_complex"; -pub const MARK_LANGUAGE_EAST_ASIAN: &str = "language_east_asian"; -pub const MARK_LETTER_SPACING: &str = "letter_spacing"; -pub const MARK_WORD_SPACING: &str = "word_spacing"; -pub const MARK_SCALE: &str = "scale"; -pub const MARK_SMALL_CAPS: &str = "small_caps"; -pub const MARK_ALL_CAPS: &str = "all_caps"; -pub const MARK_SHADOW: &str = "shadow"; -pub const MARK_KERNING: &str = "kerning"; -/// Named character style (`StyledRun::style_id`) carried as a mark so that -/// run-level style references survive Loro round-trips. -pub const MARK_CHAR_STYLE_ID: &str = "char_style_id"; -pub const MARK_OUTLINE: &str = "outline"; - -// ----------------------------------------------------------------------------- -// Inline objects (anchored by a placeholder char + a data-bearing mark) -// ----------------------------------------------------------------------------- - -/// Object-replacement character (U+FFFC) — the in-text anchor for an inline -/// object whose structured data is carried by a mark over the single anchor -/// position. The anchor occupies one Unicode scalar so the object is a -/// discrete, positioned, deletable element in the flat text stream. -pub const OBJECT_REPLACEMENT_CHAR: char = '\u{FFFC}'; - -/// String form of [`OBJECT_REPLACEMENT_CHAR`] for `LoroText::insert`. -pub const OBJECT_REPLACEMENT_STR: &str = "\u{FFFC}"; - -/// Inline image data: a `serde`-JSON snapshot of the `Inline::Image`, carried -/// as a mark over a single [`OBJECT_REPLACEMENT_CHAR`] anchor so that -/// image-bearing paragraphs round-trip *natively* (the image is a live, -/// positioned inline object) instead of as opaque block snapshots. -pub const MARK_IMAGE: &str = "image"; - -/// Inline footnote/endnote reference: carried as a mark over a single -/// [`OBJECT_REPLACEMENT_CHAR`] anchor. The value is a `serde`-JSON -/// `(NoteKind, usize)` pair — the note's kind and the index of its **body** in -/// the block's [`KEY_NOTES`] container. The `usize` index also makes each note's -/// mark unique, so adjacent notes do not merge into one rich-text delta span. -/// -/// The body itself is a **live CRDT container** (not a JSON blob in the mark), -/// so footnote text is editable and mergeable like a table cell's. -pub const MARK_NOTE: &str = "note"; - -/// Key (within a paragraph-like block's map) for the block's note bodies — a -/// movable list with one entry per [`MARK_NOTE`] anchor in the block, each a -/// movable list of the note body's blocks (written via the shared block path). -/// Indexed by the `usize` carried in each anchor's [`MARK_NOTE`] mark. -pub const KEY_NOTES: &str = "notes"; - -/// Every inline-object anchor mark key (carried over a single -/// [`OBJECT_REPLACEMENT_CHAR`]). Registered with non-expanding behaviour and -/// shared by the write/read paths. Keep in sync as new inline objects migrate -/// off the opaque-snapshot fallback. -pub const INLINE_OBJECT_MARK_KEYS: &[&str] = &[MARK_IMAGE, MARK_NOTE]; - -/// Every character-level mark key (formatting that lives on a text range). -/// -/// Single source of truth shared by `document_to_loro` (which registers each -/// key's `expand` behaviour) and `replace_text` (which resets an inserted -/// range's full formatting). Keep this in sync with the read/write paths. -pub const CHAR_MARK_KEYS: &[&str] = &[ - MARK_BOLD, - MARK_ITALIC, - MARK_UNDERLINE, - MARK_STRIKETHROUGH, - MARK_COLOR, - MARK_HIGHLIGHT_COLOR, - MARK_FONT_FAMILY, - MARK_FONT_SIZE_PT, - MARK_VERTICAL_ALIGN, - MARK_LINK_URL, - MARK_LANGUAGE, - MARK_LANGUAGE_COMPLEX, - MARK_LANGUAGE_EAST_ASIAN, - MARK_LETTER_SPACING, - MARK_WORD_SPACING, - MARK_SCALE, - MARK_SMALL_CAPS, - MARK_ALL_CAPS, - MARK_SHADOW, - MARK_KERNING, - MARK_OUTLINE, - MARK_CHAR_STYLE_ID, -]; - // ----------------------------------------------------------------------------- // ParaProps Keys // ----------------------------------------------------------------------------- diff --git a/loki-doc-model/tests/loro_bridge_inline_tail_tests.rs b/loki-doc-model/tests/loro_bridge_inline_tail_tests.rs new file mode 100644 index 00000000..0e955346 --- /dev/null +++ b/loki-doc-model/tests/loro_bridge_inline_tail_tests.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Loro bridge round-trip tests for the inline "tail" fixes: non-Rgb character +//! colors (Theme/Cmyk), comment/bookmark anchors, and quote-type / span-attr +//! range marks — all previously dropped or collapsed by the bridge. + +use loki_doc_model::content::annotation::comment::{CommentRef, CommentRefKind}; +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{BookmarkKind, Inline, QuoteType, StyledRun}; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document}; +use loki_doc_model::style::props::char_props::CharProps; +use loki_primitives::color::{CmykColor, DocumentColor, ThemeColorSlot}; + +fn round_trip_inlines(inlines: Vec) -> Vec { + let mut doc = Document::new(); + doc.sections[0].blocks.push(Block::Para(inlines)); + let loro = document_to_loro(&doc).expect("document_to_loro must succeed"); + let recovered = loro_to_document(&loro).expect("loro_to_document must succeed"); + match &recovered.sections[0].blocks[0] { + Block::Para(inlines) => inlines.clone(), + other => panic!("expected Para, got {other:?}"), + } +} + +fn styled_run(text: &str, props: CharProps) -> Inline { + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(props)), + content: vec![Inline::Str(text.into())], + attr: NodeAttr::default(), + }) +} + +fn recovered_color(inlines: &[Inline]) -> Option { + inlines.iter().find_map(|i| { + if let Inline::StyledRun(run) = i { + run.direct_props.as_ref().and_then(|p| p.color.clone()) + } else { + None + } + }) +} + +// ── Non-Rgb character colors ────────────────────────────────────────────────── + +#[test] +fn theme_char_color_survives_roundtrip() { + let color = DocumentColor::Theme { + slot: ThemeColorSlot::Accent2, + tint: -0.5, + }; + let props = CharProps { + color: Some(color.clone()), + ..Default::default() + }; + let recovered = round_trip_inlines(vec![styled_run("themed", props)]); + assert_eq!(recovered_color(&recovered), Some(color)); +} + +#[test] +fn cmyk_char_color_survives_roundtrip() { + let color = DocumentColor::Cmyk(CmykColor::new(0.9, 0.1, 0.0, 0.25)); + let props = CharProps { + color: Some(color.clone()), + ..Default::default() + }; + let recovered = round_trip_inlines(vec![styled_run("print", props)]); + assert_eq!(recovered_color(&recovered), Some(color)); +} + +// ── Comment / bookmark anchors ──────────────────────────────────────────────── + +#[test] +fn comment_anchors_survive_roundtrip() { + let start = Inline::Comment(CommentRef::new("c1", CommentRefKind::Start)); + let end = Inline::Comment(CommentRef::new("c1", CommentRefKind::End)); + let recovered = round_trip_inlines(vec![ + Inline::Str("before ".into()), + start.clone(), + Inline::Str("commented".into()), + end.clone(), + Inline::Str(" after".into()), + ]); + let anchors: Vec<_> = recovered + .iter() + .filter(|i| matches!(i, Inline::Comment(_))) + .collect(); + assert_eq!(anchors.len(), 2, "both comment anchors must survive"); + assert_eq!(anchors[0], &start); + assert_eq!(anchors[1], &end); + // The anchors must stay positioned between the text runs. + let text: String = recovered + .iter() + .map(|i| match i { + Inline::Str(s) => s.as_str(), + _ => "|", + }) + .collect(); + assert_eq!(text, "before |commented| after"); +} + +#[test] +fn bookmark_markers_survive_roundtrip() { + let start = Inline::Bookmark(BookmarkKind::Start, "bm-1".into()); + let end = Inline::Bookmark(BookmarkKind::End, "bm-1".into()); + let recovered = round_trip_inlines(vec![ + start.clone(), + Inline::Str("marked".into()), + end.clone(), + ]); + let marks: Vec<_> = recovered + .iter() + .filter(|i| matches!(i, Inline::Bookmark(_, _))) + .collect(); + assert_eq!(marks.len(), 2, "both bookmark markers must survive"); + assert_eq!(marks[0], &start); + assert_eq!(marks[1], &end); +} + +// ── Quote type / span attrs ─────────────────────────────────────────────────── + +#[test] +fn quoted_text_keeps_quote_type() { + for quote_type in [QuoteType::SingleQuote, QuoteType::DoubleQuote] { + let quoted = Inline::Quoted(quote_type, vec![Inline::Str("quoted".into())]); + let recovered = round_trip_inlines(vec![quoted.clone()]); + assert_eq!( + recovered, + vec![quoted], + "{quote_type:?} must survive Loro round-trip" + ); + } +} + +#[test] +fn span_keeps_node_attr() { + let mut attr = NodeAttr::default(); + attr.id = Some("term-3".into()); + attr.classes.push("glossary-term".into()); + attr.kv.push(("data-ref".into(), "g3".into())); + let span = Inline::Span(attr, vec![Inline::Str("spanned".into())]); + let recovered = round_trip_inlines(vec![span.clone()]); + assert_eq!(recovered, vec![span]); +} + +#[test] +fn quoted_span_nesting_order_is_preserved() { + let mut attr = NodeAttr::default(); + attr.classes.push("inner".into()); + let quoted_span = Inline::Quoted( + QuoteType::DoubleQuote, + vec![Inline::Span(attr, vec![Inline::Str("both".into())])], + ); + let recovered = round_trip_inlines(vec![quoted_span.clone()]); + assert_eq!(recovered, vec![quoted_span]); +} diff --git a/loki-doc-model/tests/loro_bridge_tests.rs b/loki-doc-model/tests/loro_bridge_tests.rs index a5e76a6e..c458fc23 100644 --- a/loki-doc-model/tests/loro_bridge_tests.rs +++ b/loki-doc-model/tests/loro_bridge_tests.rs @@ -498,7 +498,7 @@ fn roundtrip_rgb_color_mark() { } #[test] -fn roundtrip_transparent_color_graceful_drop() { +fn roundtrip_transparent_color_survives() { let mut doc = Document::new(); let props = CharProps { color: Some(DocumentColor::Transparent), @@ -517,17 +517,18 @@ fn roundtrip_transparent_color_graceful_drop() { let loro = document_to_loro(&doc).expect("serialize"); let doc2 = loro_to_document(&loro).expect("deserialize — must not crash"); - // Transparent has no hex representation so the mark is dropped gracefully + // The total DocumentColor codec carries every variant, including + // Transparent (formerly dropped for lack of a hex representation). if let Block::Para(inlines) = &doc2.sections[0].blocks[0] { match &inlines[0] { Inline::StyledRun(run) => { let color = run.direct_props.as_ref().and_then(|p| p.color.as_ref()); - assert!( - color.is_none(), - "Transparent should not round-trip as a color value" + assert_eq!( + color, + Some(&DocumentColor::Transparent), + "Transparent must round-trip as a color value" ); } - Inline::Str(_) => {} // also acceptable — no mark means plain Str other => panic!("unexpected inline variant: {other:?}"), } } else { From 11b2e37bc4d2108419fe14dc71f320997ec6e399 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 00:17:22 +0000 Subject: [PATCH 06/27] feat: compact Loro CRDT history at the save point (memory Finding 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oplog grows ~2 ops per keystroke and was never compacted, so a long editing session retained unbounded history (observed >3 GB in the memory audit). The save point is the natural horizon: the file is then the durable state, so history behind it only serves undo. - loki-doc-model: new loro_bridge::compact with compact_in_place (re-encode the change store + free checkout caches; history and undo preserved) and compact_history (minimal-history StateOnly snapshot re-imported into a fresh doc with the mark expand config re-registered — extracted as configure_text_style and shared with document_to_loro). - loki-text: editor_compact::compact_after_save runs after every successful save — in-place below 20k ops, full truncation above it (swap the loro_doc signal, recreate the UndoManager so undo restarts at the save point, drop the IncrementalReader seed). The ribbon Save button now routes through the shared Ctrl+S handler instead of duplicating the save logic, so compaction and the clean baseline live in one place; editor_ribbon.rs dropped below the 300-line ceiling and leaves the ratchet baseline (35 → 34). - Bench acceptance (leak_loro_history, new compacted phase, asserted): 5000 keystrokes = 19208 ops uncompacted vs 1 op with periodic compaction; memory-audit Finding 6 marked fixed with the on-device long-session validation noted as the remaining follow-up (BM-14). Phase 1.5 of docs/deferred-features-plan-2026-07-04.md — Phase 1 complete. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 4 +- docs/deferred-features-plan-2026-07-04.md | 2 +- docs/memory-audit-2026-06-12.md | 32 +++--- loki-bench/benches/leak_loro_history.rs | 38 ++++++- loki-doc-model/src/loro_bridge/compact.rs | 85 ++++++++++++++ loki-doc-model/src/loro_bridge/mod.rs | 27 +---- .../tests/loro_bridge_compact_tests.rs | 106 ++++++++++++++++++ loki-text/src/routes/editor/editor_compact.rs | 70 ++++++++++++ loki-text/src/routes/editor/editor_inner.rs | 13 ++- loki-text/src/routes/editor/editor_ribbon.rs | 29 ++--- loki-text/src/routes/editor/mod.rs | 1 + scripts/file-ceiling-baseline.txt | 11 +- 12 files changed, 338 insertions(+), 80 deletions(-) create mode 100644 loki-doc-model/src/loro_bridge/compact.rs create mode 100644 loki-doc-model/tests/loro_bridge_compact_tests.rs create mode 100644 loki-text/src/routes/editor/editor_compact.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 88c83562..68eca9ae 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -44,7 +44,7 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe |---|---|---| | `3b-3` (partial) | `navigation.rs` (many) | Cross-page nav: up/down works; **left/right at page edges + `page_index` recompute after split/merge still `None`** | | `loro-bridge` | `loro_bridge/decode.rs` (borders), `table.rs:25` | ~~Non-Rgb colors, comment/bookmark anchors, quote/span attrs~~ **fixed 2026-07-04** (plan Phase 1.4). Remaining: non-Rgb *border* colors (format migration), `Cite` metadata, structural-table CRDT semantics | -| `loro-compaction` | `loki-bench/benches/leak_loro_history.rs`; bridge | Compact the CRDT oplog at save/undo-horizon (memory Finding 6) | +| `loro-compaction` | `loki-bench/benches/leak_loro_history.rs`; bridge | ~~Compact the CRDT oplog~~ **fixed 2026-07-04** (plan Phase 1.5): `loro_bridge::compact` + save-point wiring in loki-text; bench asserts the flattened curve. On-device validation pending (BM-14) | | `omml` | `docx/omml/mod.rs:20` | OMML↔MathML for delimiters, n-ary, matrices, accents | | `link-click` | `resolve.rs:689`, `items.rs:125`, `para.rs:203`, `scene.rs:519` | Interactive hyperlink hit-testing (only a visual hint today) | | `shadow` | `para_emit.rs:187`, `para.rs:196`, `scene.rs:93` | True soft text shadow (Vello blur) — hard grey offset copy today | @@ -132,7 +132,7 @@ Still-open after verification (the DONE-SINCE ones moved to §1). |---|---|---| | memory-audit F3 | Drop preserved layout for inactive tabs (`sessions.rs:39` still retains `Arc`) | STILL-OPEN | | memory-audit F5 | Share render `FontDataCache` (per-tile `page_paint_source.rs:53` vs shared `DocPageSource`) | STILL-OPEN | -| memory-audit F6 | Compact Loro oplog (`TODO(loro-compaction)`) | STILL-OPEN | +| memory-audit F6 | Compact Loro oplog (`TODO(loro-compaction)`) | ~~STILL-OPEN~~ **FIXED 2026-07-04** (plan Phase 1.5) | | audit-2026-06 Q-1 | 300-line ceiling backlog — 43→**35**, CI-ratcheted (PARTIAL; `para.rs`/`flow.rs` grew) | PARTIAL | | audit-2026-06 Q-2 | App-shell duplication (per-app `routes/`, `shell.rs`) | PARTIAL | | audit-2026-06 Q-3/Q-4 | 301 `let _ =` writer error-swallows (downgraded); ~100 `#[allow]` incl. 32 `dead_code` OOXML | STILL-OPEN (downgraded/P2) | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 82be2383..b7be5bae 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -47,7 +47,7 @@ closest thing to data loss in the suite. These are all code-confirmed in audit | 1.2 | §6 | ✅ **Done 2026-07-04** — total `DocumentColor` codec (`loro_bridge/color_codec.rs`, Rgb/Cmyk/Theme/Transparent) + reader; `bridge_para_background_color_roundtrip`. | S | | 1.3 | §6 | ✅ **Done 2026-07-04** — native mappings for `BulletList`/`OrderedList`/`BlockQuote`/`Div`/`Figure` (`loro_bridge/containers.rs`, table.rs pattern); `loro_bridge_container_tests.rs`. `DefinitionList` + inline fields/math stay opaque. | M | | 1.4 | §2 | ✅ **Done 2026-07-04** — char colors use the total `DocumentColor` codec (Theme/Cmyk/Transparent survive, mark + block-map paths); comment/bookmark anchors preserved as `OBJECT_REPLACEMENT_CHAR` snapshot marks; `Quoted` quote-type and `Span` attrs carried as range marks with recursive child writes; `loro_bridge_inline_tail_tests.rs`. Remaining TODO(loro-bridge): non-Rgb *border* colors (colon-format migration), `Cite` metadata, structural-table CRDT semantics. | M | -| 1.5 | §2, §5 | **Loro oplog compaction** (`TODO(loro-compaction)`, memory Finding 6, Spec 06): compact at save/undo-horizon. `loki-bench/benches/leak_loro_history.rs` is the pre-built yardstick — use it as the acceptance test (history must stop growing linearly with edit count past the undo horizon). Server-side ADR-C013 `Compactor` already exists as prior art. | M | +| 1.5 | §2, §5 | ✅ **Done 2026-07-04** — `loro_bridge::compact` (`compact_in_place` / `compact_history`) wired at the save point in loki-text (`editor_compact.rs`; ribbon Save unified into the Ctrl+S handler). Bench acceptance passed: 5 000 keystrokes = 19 208 ops uncompacted → 1 op compacted, asserted in `leak_loro_history`. On-device long-session validation still pending (BM-14). | M | **Exit criteria**: `metadata_round_trip.rs`-style tests pass for 1.1–1.3; the `leak_loro_history` bench shows bounded history; the two §6 tech-debt rows and diff --git a/docs/memory-audit-2026-06-12.md b/docs/memory-audit-2026-06-12.md index 473c4e5a..6cf59fae 100644 --- a/docs/memory-audit-2026-06-12.md +++ b/docs/memory-audit-2026-06-12.md @@ -154,22 +154,16 @@ RUST_LOG=loki_text::mem=info `loro_ops` / `loro_changes` climbing without bound while `pages` / `blocks` stay flat confirms the history (not the layout or document) is what grows. -**Proposed fix (compaction — needs on-device validation, not yet applied):** -Loro can drop history before a frontier via -`export(ExportMode::shallow_snapshot(&doc.oplog_frontiers()))` re-imported into a -fresh `LoroDoc`. Doing this at a natural checkpoint (e.g. on **save**, or after -the undo horizon of 100 steps is exceeded) caps the oplog. The cost: the swap -invalidates the `UndoManager` (recreate it), the `IncrementalReader` version -(reseed it), and every signal/`DocSession` holding the old `LoroDoc` (re-point -them), and it truncates undo history at the compaction point. Because that -touches the live editing/undo wiring and can't be validated headlessly, it is -recorded here as the next step rather than applied blind. - - -### Tech debt - -- **TODO(loro-compaction):** the Loro history (oplog + rich-text tombstones) is - never compacted, so a long single-document editing session grows resident - memory without bound (observed >3 GB). Compact via a shallow-snapshot swap at a - safe checkpoint; requires recreating the `UndoManager`/`IncrementalReader` and - re-pointing the `loro_doc` signal, plus on-device validation. +**Fix applied (2026-07-04, plan Phase 1.5):** `loro_bridge::compact` now +provides `compact_in_place` (re-encode the change store + drop checkout +caches; history and undo preserved) and `compact_history` (minimal-history +`StateOnly` snapshot re-imported into a fresh mark-configured `LoroDoc`). +loki-text wires both at the save point (`editor_compact::compact_after_save`, +called from the unified save handler): below 20k ops the in-place pass runs; +above it the doc is swapped, recreating the `UndoManager` (undo restarts at +the save point) and dropping the `IncrementalReader` seed exactly as +anticipated above. The `leak_loro_history` bench now runs a compacted phase +and asserts the curve flattens (5 000 keystrokes: 19 208 ops uncompacted → +1 op with periodic compaction). **On-device long-session validation is still +pending** — the swap path exercises real editing/undo wiring that only a +device run can confirm (tracked in Spec 06 BM-14's on-device pass). diff --git a/loki-bench/benches/leak_loro_history.rs b/loki-bench/benches/leak_loro_history.rs index 7e5b246e..74cbf893 100644 --- a/loki-bench/benches/leak_loro_history.rs +++ b/loki-bench/benches/leak_loro_history.rs @@ -9,8 +9,12 @@ //! tombstones grow: the op/change counters (`len_ops` / `len_changes`, the same //! signals `loki_text::mem` logs) and the live heap the session retains. This //! confirms and quantifies the known unbounded growth (memory-audit Finding 6 / -//! `TODO(loro-compaction)`); it is the yardstick a future compaction fix is -//! validated against — a fix should flatten this curve. +//! `TODO(loro-compaction)` — now addressed by `loro_bridge::compact`). +//! +//! A second phase runs the same workload with `compact_history` applied every +//! `COMPACT_EVERY` keystrokes (the editor applies it after saves) and asserts +//! the curve flattens: the compacted session's final oplog must be a small +//! fraction of the uncompacted one. //! //! Run: `cargo bench -p loki-bench --bench leak_loro_history` @@ -20,10 +24,12 @@ loki_bench::dhat_global_allocator!(); mod support; use loki_bench::residual_after; +use loki_doc_model::loro_bridge::compact_history; use loki_doc_model::{delete_text, document_to_loro, insert_text}; use std::hint::black_box; const KEYSTROKES: usize = 5_000; +const COMPACT_EVERY: usize = 1_000; fn main() { let doc = support::build_doc(20, support::WORDS_PER_PARA); @@ -55,11 +61,35 @@ fn main() { residual.curr_bytes / KEYSTROKES as u64, ); eprintln!( - " → history grows with edit count (Finding 6 / TODO(loro-compaction)); \ - a compaction fix should flatten this." + " → history grows with edit count (Finding 6); the compacted phase \ + below must flatten this." ); // Measured & reported (not a pass/fail): editing must move the oplog. assert!(ops1 > ops0, "oplog did not grow — did the edits apply?"); black_box(&loro); + + // ── Phase 2: same workload, compacting every COMPACT_EVERY keystrokes ── + let doc2 = support::build_doc(20, support::WORDS_PER_PARA); + let mut compacted = document_to_loro(&doc2).expect("document_to_loro"); + for i in 0..KEYSTROKES { + let _ = insert_text(&compacted, 0, 0, "x"); + let _ = delete_text(&compacted, 0, 0, 1); + if (i + 1) % COMPACT_EVERY == 0 { + compacted = compact_history(&compacted).expect("compact_history"); + } + } + let ops_compacted = compacted.len_ops(); + eprintln!( + "with compact_history every {COMPACT_EVERY} keystrokes:\n \ + ops after {KEYSTROKES} keystrokes: {ops_compacted} (uncompacted: {ops1})" + ); + + // The whole point of the fix: history no longer grows with session time. + // Bound = one compaction window's worth of ops plus the baseline. + assert!( + ops_compacted < ops0 + 3 * COMPACT_EVERY, + "compacted oplog did not flatten: {ops_compacted} ops" + ); + black_box(&compacted); } diff --git a/loki-doc-model/src/loro_bridge/compact.rs b/loki-doc-model/src/loro_bridge/compact.rs new file mode 100644 index 00000000..68082941 --- /dev/null +++ b/loki-doc-model/src/loro_bridge/compact.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Client-side CRDT history compaction (memory-audit Finding 6 / +//! `TODO(loro-compaction)`). +//! +//! A Loro oplog grows with every edit — with session *time*, not document +//! size — so a long editing session retains unbounded history. Two remedies, +//! in increasing strength: +//! +//! - [`compact_in_place`]: re-encode parsed ops into Loro's compact change +//! store and drop checkout caches. No history is lost, undo and +//! subscriptions keep working; only the memory *representation* shrinks. +//! Safe to call at any quiescent point (e.g. after every save). +//! - [`compact_history`]: export a minimal-history snapshot of the current +//! state and import it into a fresh, mark-configured [`LoroDoc`]. This +//! truncates the oplog itself — the durable fix — at the cost that +//! anything bound to the old doc instance (an `UndoManager`, container +//! handles, subscriptions, an `IncrementalReader` seed) must be recreated +//! by the caller, and undo history restarts at the compaction point. +//! Client-local documents only: a peer that is behind the shallow start +//! can no longer sync updates (server-relayed Tier-0/1 documents are +//! compacted by the ADR-C013 server `Compactor`; Tier-2 by the +//! `PUT /snapshot` flow). + +use super::BridgeError; +use crate::loro_schema::{CHAR_MARK_KEYS, INLINE_OBJECT_MARK_KEYS}; +use loro::{ExpandType, ExportMode, LoroDoc, StyleConfig, StyleConfigMap}; + +/// Registers every schema mark key's expand behaviour on `doc`. +/// +/// Must run on each fresh [`LoroDoc`] before it carries document text — +/// [`super::document_to_loro`] and [`compact_history`] both call it. The +/// config is per-instance runtime state; it does not travel in snapshots. +pub(super) fn configure_text_style(doc: &LoroDoc) { + let mut style_config = StyleConfigMap::new(); + // Character formatting marks expand onto text inserted at their trailing + // edge (`After`) — the single source of truth is `CHAR_MARK_KEYS`. + for key in CHAR_MARK_KEYS { + style_config.insert( + loro::InternalString::from(*key), + StyleConfig { + expand: ExpandType::After, + }, + ); + } + // Inline-object anchor marks must not expand onto adjacent text — they + // describe a single placeholder position, not a formatting span. + for key in INLINE_OBJECT_MARK_KEYS { + style_config.insert( + loro::InternalString::from(*key), + StyleConfig { + expand: ExpandType::None, + }, + ); + } + doc.config_text_style(style_config); +} + +/// In-place memory compaction: commit, re-encode the change store, and free +/// checkout caches. History (and therefore undo) is fully preserved. +pub fn compact_in_place(doc: &LoroDoc) { + doc.commit(); + doc.compact_change_store(); + doc.free_history_cache(); + doc.free_diff_calculator(); +} + +/// History truncation: returns a **fresh** [`LoroDoc`] holding the same +/// document state with a minimal-depth oplog (Loro `StateOnly` shallow +/// snapshot at the latest version). +/// +/// The caller must swap the returned doc in for the old one and recreate +/// everything bound to the old instance — see the module docs for the list +/// and the sync caveat. +pub fn compact_history(doc: &LoroDoc) -> Result { + doc.commit(); + let bytes = doc + .export(ExportMode::StateOnly(None)) + .map_err(|e| BridgeError::Loro(e.to_string()))?; + let fresh = LoroDoc::new(); + configure_text_style(&fresh); + fresh.import(&bytes)?; + Ok(fresh) +} diff --git a/loki-doc-model/src/loro_bridge/mod.rs b/loki-doc-model/src/loro_bridge/mod.rs index 60a3136b..6347c368 100644 --- a/loki-doc-model/src/loro_bridge/mod.rs +++ b/loki-doc-model/src/loro_bridge/mod.rs @@ -11,6 +11,7 @@ mod color_codec; mod comments; +mod compact; mod containers; mod decode; mod incremental; @@ -28,6 +29,7 @@ mod table; mod write; pub use comments::{read_document_comments, write_document_comments}; +pub use compact::{compact_history, compact_in_place}; pub use incremental::IncrementalReader; pub use meta::{read_document_meta, write_document_meta}; pub use styles::{read_document_styles, write_document_styles}; @@ -43,7 +45,7 @@ use crate::document::Document; use crate::layout::header_footer::HeaderFooter; use crate::layout::page::{PageLayout, PageOrientation}; use crate::loro_schema::*; -use loro::{ExpandType, LoroDoc, LoroMap, LoroMovableList, StyleConfig, StyleConfigMap}; +use loro::{LoroDoc, LoroMap, LoroMovableList}; use read::{reconstruct_blocks_from_list, reconstruct_page_layout}; use write::map_blocks_to_list; @@ -130,28 +132,7 @@ pub fn document_to_loro(doc: &Document) -> Result { let loro_doc = LoroDoc::new(); // Register every mark key so Loro tracks its expand behaviour. - let mut style_config = StyleConfigMap::new(); - // Character formatting marks expand onto text inserted at their trailing - // edge (`After`) — the single source of truth is `CHAR_MARK_KEYS`. - for key in CHAR_MARK_KEYS { - style_config.insert( - loro::InternalString::from(*key), - StyleConfig { - expand: ExpandType::After, - }, - ); - } - // Inline-object anchor marks must not expand onto adjacent text — they - // describe a single placeholder position, not a formatting span. - for key in INLINE_OBJECT_MARK_KEYS { - style_config.insert( - loro::InternalString::from(*key), - StyleConfig { - expand: ExpandType::None, - }, - ); - } - loro_doc.config_text_style(style_config); + compact::configure_text_style(&loro_doc); // Metadata — full DocumentMeta (core + Dublin Core) as a lossless snapshot. let meta_map = loro_doc.get_map(KEY_METADATA); diff --git a/loki-doc-model/tests/loro_bridge_compact_tests.rs b/loki-doc-model/tests/loro_bridge_compact_tests.rs new file mode 100644 index 00000000..2bc54dc3 --- /dev/null +++ b/loki-doc-model/tests/loro_bridge_compact_tests.rs @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Tests for CRDT history compaction (`loro_bridge::compact`, memory-audit +//! Finding 6): compaction must shrink the oplog without changing the +//! document, and the compacted doc must remain fully editable. + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::{ + compact_history, compact_in_place, document_to_loro, loro_to_document, +}; +use loki_doc_model::{delete_text, insert_text}; + +/// A document with one paragraph, put through `n` insert+delete keystroke +/// pairs so the oplog grows while the content stays fixed. +fn edited_doc(n: usize) -> loro::LoroDoc { + let mut doc = Document::new(); + doc.sections[0] + .blocks + .push(Block::Para(vec![Inline::Str("stable text".into())])); + let loro = document_to_loro(&doc).expect("document_to_loro"); + for _ in 0..n { + insert_text(&loro, 0, 0, "x").expect("insert"); + delete_text(&loro, 0, 0, 1).expect("delete"); + } + loro +} + +#[test] +fn compact_history_truncates_oplog_and_preserves_content() { + let loro = edited_doc(500); + let before = loro_to_document(&loro).expect("read before"); + let ops_before = loro.len_ops(); + + let compacted = compact_history(&loro).expect("compact_history"); + + let after = loro_to_document(&compacted).expect("read after"); + assert_eq!( + before.sections[0].blocks, after.sections[0].blocks, + "compaction must not change the document content" + ); + assert!( + compacted.len_ops() < ops_before / 10, + "oplog must shrink by at least 10x (was {ops_before}, now {})", + compacted.len_ops() + ); +} + +#[test] +fn compacted_doc_remains_editable_with_marks() { + let loro = edited_doc(100); + let compacted = compact_history(&loro).expect("compact_history"); + + // Edits must apply to the compacted doc and round-trip; the text-style + // config (mark expand behaviour) must have been re-registered. + insert_text(&compacted, 0, 0, "hello ").expect("insert after compaction"); + let doc = loro_to_document(&compacted).expect("read"); + match &doc.sections[0].blocks[0] { + Block::Para(inlines) => { + let text: String = inlines + .iter() + .map(|i| match i { + Inline::Str(s) => s.clone(), + other => panic!("unexpected inline: {other:?}"), + }) + .collect(); + assert_eq!(text, "hello stable text"); + } + other => panic!("expected Para, got {other:?}"), + } +} + +#[test] +fn compact_history_is_repeatable() { + // Compact, edit, compact again — the second pass must also succeed and + // keep the state (guards against one-shot assumptions in the swap). + let loro = edited_doc(100); + let once = compact_history(&loro).expect("first compaction"); + insert_text(&once, 0, 0, "more ").expect("edit"); + let twice = compact_history(&once).expect("second compaction"); + let doc = loro_to_document(&twice).expect("read"); + match &doc.sections[0].blocks[0] { + Block::Para(inlines) => { + assert!(matches!(&inlines[0], Inline::Str(s) if s.starts_with("more "))); + } + other => panic!("expected Para, got {other:?}"), + } +} + +#[test] +fn compact_in_place_preserves_history_and_content() { + let loro = edited_doc(200); + let before = loro_to_document(&loro).expect("read before"); + let ops_before = loro.len_ops(); + + compact_in_place(&loro); + + // History is preserved (this is the memory-representation compaction), + // the document is unchanged, and the doc stays editable. + assert_eq!(loro.len_ops(), ops_before, "history must be preserved"); + let after = loro_to_document(&loro).expect("read after"); + assert_eq!(before.sections[0].blocks, after.sections[0].blocks); + insert_text(&loro, 0, 0, "y").expect("still editable"); +} diff --git a/loki-text/src/routes/editor/editor_compact.rs b/loki-text/src/routes/editor/editor_compact.rs new file mode 100644 index 00000000..5b691b4a --- /dev/null +++ b/loki-text/src/routes/editor/editor_compact.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Post-save CRDT history compaction (memory-audit Finding 6). +//! +//! A Loro oplog grows with every keystroke, so a long session retains +//! unbounded history. The save point is the natural horizon: the file is now +//! the durable state, so history behind it only serves undo. After each +//! successful save this module either +//! +//! - **truncates** the history (`compact_history`) when the oplog has grown +//! past [`COMPACT_THRESHOLD_OPS`] — swapping in a fresh doc and resetting +//! the undo stack to the save point, or +//! - **re-encodes** it in place (`compact_in_place`) below the threshold — +//! free memory savings with undo fully preserved. +//! +//! The threshold keeps the undo-reset tradeoff rare: routine saves keep +//! their undo history; only marathon sessions pay with a truncated stack. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use loro::LoroDoc; + +use crate::editing::state::DocumentState; + +/// Oplog size (in ops) above which a save triggers full history truncation. +/// ~2 ops per keystroke → roughly 10k keystrokes since the last truncation. +const COMPACT_THRESHOLD_OPS: usize = 20_000; + +/// Compacts the CRDT history after a successful save. See the module docs +/// for the threshold behaviour. +pub(super) fn compact_after_save( + mut loro_doc: Signal>, + mut undo_manager: Signal>, + mut can_undo: Signal, + mut can_redo: Signal, + doc_state: &Arc>, +) { + // Clone the handle (cheap, shared) so the read guard drops before set(). + let Some(doc) = loro_doc.peek().clone() else { + return; + }; + + if doc.len_ops() < COMPACT_THRESHOLD_OPS { + loki_doc_model::loro_bridge::compact_in_place(&doc); + return; + } + + match loki_doc_model::loro_bridge::compact_history(&doc) { + Ok(fresh) => { + // Everything bound to the old doc instance is recreated: the + // undo manager restarts at the save point, and the incremental + // reader re-seeds from the new doc on the next edit. + let um = loro::UndoManager::new(&fresh); + loro_doc.set(Some(fresh)); + undo_manager.set(Some(um)); + can_undo.set(false); + can_redo.set(false); + let mut state = doc_state.lock().unwrap_or_else(|e| e.into_inner()); + state.incremental = None; + } + Err(err) => { + // The save itself succeeded; a failed compaction only means the + // memory win is skipped this time. Keep the working doc. + tracing::warn!("post-save history compaction failed: {err}"); + loki_doc_model::loro_bridge::compact_in_place(&doc); + } + } +} diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index b3bbeda0..325470d5 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -569,6 +569,15 @@ pub(super) fn EditorInner(path: String) -> Element { let msg = match save_document_to_path(&path, &doc_state_savereq) { Ok(()) => { baseline_gen.set(cursor_state.peek().document_generation); + // The file is now the durable state — bound the CRDT history + // memory (memory-audit Finding 6; see editor_compact.rs). + super::editor_compact::compact_after_save( + loro_doc, + undo_manager, + can_undo, + can_redo, + &doc_state_savereq, + ); fl!("editor-save-success") } Err(e) => fl!("editor-save-error", reason = e.to_string()), @@ -813,12 +822,10 @@ pub(super) fn EditorInner(path: String) -> Element { subscript_active, current_style_name, is_style_picker_open, - path_signal, - save_message, + save_request, editing_style_draft, save_as, save_as_template, - baseline_gen, ), }, } diff --git a/loki-text/src/routes/editor/editor_ribbon.rs b/loki-text/src/routes/editor/editor_ribbon.rs index 352b03ab..1e05e207 100644 --- a/loki-text/src/routes/editor/editor_ribbon.rs +++ b/loki-text/src/routes/editor/editor_ribbon.rs @@ -19,11 +19,9 @@ use loro::LoroDoc; use crate::editing::cursor::CursorState; use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; -use crate::new_document::is_untitled; use super::editor_formatting; use super::editor_keydown_ctrl::post_mutation_sync; -use super::editor_save::save_document_to_path; use super::editor_state::StyleDraft; use super::editor_style_catalog::get_catalog_style; use super::editor_style_editor::style_to_draft; @@ -53,15 +51,12 @@ pub(super) fn write_tab_content( subscript_active: Signal, current_style_name: String, mut is_style_picker_open: Signal, - path_signal: Signal, - mut save_message: Signal>, + mut save_request: Signal, mut editing_style_draft: Signal>, save_as: Callback<()>, save_as_template: Callback<()>, - mut baseline_gen: Signal, ) -> Element { // One Arc clone per button — cheap reference-count increment. - let ds_save = Arc::clone(doc_state); let ds_para = Arc::clone(doc_state); let current_style_name_para = current_style_name.clone(); let ds_undo = Arc::clone(doc_state); @@ -84,22 +79,12 @@ pub(super) fn write_tab_content( is_active: false, is_disabled: false, on_click: move |_| { - let path = path_signal(); - // An untitled document has no file yet — route to Save As. - if is_untitled(&path) { - save_as.call(()); - return; - } - let msg = match save_document_to_path(&path, &ds_save) { - Ok(()) => { - // Mark the current generation as clean so the tab's - // unsaved-changes indicator clears. - baseline_gen.set(cursor_state.peek().document_generation); - fl!("editor-save-success") - } - Err(e) => fl!("editor-save-error", reason = e.to_string()), - }; - save_message.set(Some(msg)); + // Route through the shared save handler (the Ctrl+S effect + // in `EditorInner`), which owns the untitled→Save-As + // routing, the clean baseline, the status message, and + // post-save history compaction. + let next = save_request.peek().wrapping_add(1); + save_request.set(next); }, AtIcon { path_d: LUCIDE_SAVE.to_string() } } diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index fd2593b3..4597b010 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -10,6 +10,7 @@ //! All editing logic lives in [`editor_inner::EditorInner`]. mod editor_canvas; +mod editor_compact; mod editor_docked_panels; mod editor_error_view; mod editor_font_warning; diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index 2a7cc648..aa93d811 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -10,17 +10,17 @@ 1244 loki-spreadsheet/src/routes/editor/editor_inner.rs 1209 loki-ooxml/src/docx/reader/document.rs 1073 loki-ooxml/src/docx/write/document.rs -984 loki-layout/src/resolve.rs +974 loki-layout/src/resolve.rs 948 loki-vello/src/scene.rs -878 loki-text/src/routes/editor/editor_inner.rs -761 loki-layout/src/flow_para.rs +870 loki-text/src/routes/editor/editor_inner.rs +741 loki-layout/src/flow_para.rs 730 loki-ooxml/src/docx/mapper/inline.rs 651 loki-odf/src/package.rs 611 loki-ooxml/src/docx/mapper/document.rs -594 loki-text/src/editing/navigation.rs +591 loki-text/src/editing/navigation.rs 582 loki-ooxml/src/docx/mapper/props.rs 575 loki-ooxml/src/xlsx/import.rs -501 loki-text/src/routes/editor/editor_canvas.rs +491 loki-text/src/routes/editor/editor_canvas.rs 466 loki-ooxml/src/xlsx/export.rs 465 loki-odf/src/ods/import.rs 440 loki-doc-model/src/document.rs @@ -35,6 +35,5 @@ 316 loki-text/src/routes/editor/editor_style.rs 315 loki-text/src/routes/editor/editor_publish.rs 315 loki-odf/src/odt/model/styles.rs -314 loki-text/src/routes/editor/editor_ribbon.rs 314 loki-renderer/src/page_paint_source.rs 307 loki-ooxml/src/docx/mapper/table.rs From cd11b032070107ae668b056a061fdf6e6e746501 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 00:31:28 +0000 Subject: [PATCH 07/27] =?UTF-8?q?chore:=20remove=20the=20loki-file-access?= =?UTF-8?q?=20patch=20=E2=80=94=20fixes=20upstreamed=20as=200.1.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only non-upstream-gated dependency patch is gone (plan Phase 2). The full patch content — Android NativeActivity file-picking fixes, the FilePickerActivity/ImeInsetsListener Java shims and their dexing build.rs, the insets/IME bridges, and FileAccessToken::delete()/ copy_bytes_to() — was pushed to appthere/loki-file-access as v0.1.3 (commit d2b7bc5, fast-forwarded to main; 43 tests + clippy clean). - Drop the [patch."…/loki-file-access"] entry and the patches/loki-file-access/ vendored tree; the existing branch="main" git dependency now resolves to 0.1.3 directly, with zero 'Patch … was not used' warnings. - The three Android build scripts staged the Java shims from the deleted patch path; they now resolve the crate's checkout directory from cargo metadata (single source of truth stays upstream). - docs/patches.md: section moved to 'Removed patches' with the removal record (5 active patches remain, all upstream-gated). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- Cargo.lock | 3 +- Cargo.toml | 9 - android/.gitignore | 3 +- docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 8 +- docs/patches.md | 120 +- patches/loki-file-access/.cargo-ok | 1 - patches/loki-file-access/.cargo_vcs_info.json | 6 - patches/loki-file-access/.gitignore | 21 - patches/loki-file-access/Cargo.lock | 2185 ----------------- patches/loki-file-access/Cargo.toml | 50 - patches/loki-file-access/Cargo.toml.orig | 49 - patches/loki-file-access/LICENSE | 21 - .../android/FilePickerActivity.java | 141 -- .../android/ImeInsetsListener.java | 63 - patches/loki-file-access/build.rs | 192 -- .../docs/adr/0001-runtime-agnostic-future.md | 58 - .../docs/adr/0002-mit-license.md | 58 - patches/loki-file-access/src/api.rs | 166 -- patches/loki-file-access/src/error.rs | 209 -- patches/loki-file-access/src/future.rs | 182 -- patches/loki-file-access/src/lib.rs | 81 - .../src/platform/android/jni_activity.rs | 201 -- .../src/platform/android/jni_common.rs | 29 - .../src/platform/android/jni_fd.rs | 150 -- .../src/platform/android/jni_ime.rs | 149 -- .../src/platform/android/jni_insets.rs | 294 --- .../src/platform/android/jni_intents.rs | 238 -- .../src/platform/android/mod.rs | 368 --- .../src/platform/desktop/filters.rs | 153 -- .../src/platform/desktop/mod.rs | 420 ---- .../src/platform/desktop/zenity.rs | 121 - .../src/platform/ios/bookmark.rs | 118 - .../loki-file-access/src/platform/ios/mod.rs | 132 - patches/loki-file-access/src/platform/mod.rs | 65 - patches/loki-file-access/src/platform/wasm.rs | 191 -- patches/loki-file-access/src/token.rs | 412 ---- patches/loki-file-access/tests/desktop.rs | 139 -- scripts/build-aab.sh | 19 +- scripts/build-android.ps1 | 11 +- scripts/build-android.sh | 14 +- 41 files changed, 66 insertions(+), 6786 deletions(-) delete mode 100644 patches/loki-file-access/.cargo-ok delete mode 100644 patches/loki-file-access/.cargo_vcs_info.json delete mode 100644 patches/loki-file-access/.gitignore delete mode 100644 patches/loki-file-access/Cargo.lock delete mode 100644 patches/loki-file-access/Cargo.toml delete mode 100644 patches/loki-file-access/Cargo.toml.orig delete mode 100644 patches/loki-file-access/LICENSE delete mode 100644 patches/loki-file-access/android/FilePickerActivity.java delete mode 100644 patches/loki-file-access/android/ImeInsetsListener.java delete mode 100644 patches/loki-file-access/build.rs delete mode 100644 patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md delete mode 100644 patches/loki-file-access/docs/adr/0002-mit-license.md delete mode 100644 patches/loki-file-access/src/api.rs delete mode 100644 patches/loki-file-access/src/error.rs delete mode 100644 patches/loki-file-access/src/future.rs delete mode 100644 patches/loki-file-access/src/lib.rs delete mode 100644 patches/loki-file-access/src/platform/android/jni_activity.rs delete mode 100644 patches/loki-file-access/src/platform/android/jni_common.rs delete mode 100644 patches/loki-file-access/src/platform/android/jni_fd.rs delete mode 100644 patches/loki-file-access/src/platform/android/jni_ime.rs delete mode 100644 patches/loki-file-access/src/platform/android/jni_insets.rs delete mode 100644 patches/loki-file-access/src/platform/android/jni_intents.rs delete mode 100644 patches/loki-file-access/src/platform/android/mod.rs delete mode 100644 patches/loki-file-access/src/platform/desktop/filters.rs delete mode 100644 patches/loki-file-access/src/platform/desktop/mod.rs delete mode 100644 patches/loki-file-access/src/platform/desktop/zenity.rs delete mode 100644 patches/loki-file-access/src/platform/ios/bookmark.rs delete mode 100644 patches/loki-file-access/src/platform/ios/mod.rs delete mode 100644 patches/loki-file-access/src/platform/mod.rs delete mode 100644 patches/loki-file-access/src/platform/wasm.rs delete mode 100644 patches/loki-file-access/src/token.rs delete mode 100644 patches/loki-file-access/tests/desktop.rs diff --git a/Cargo.lock b/Cargo.lock index f9238383..d1438b87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4461,7 +4461,8 @@ dependencies = [ [[package]] name = "loki-file-access" -version = "0.1.2" +version = "0.1.3" +source = "git+https://github.com/appthere/loki-file-access?branch=main#d2b7bc5c0a0f1703d1df0d083a9c5d660ddcb2cb" dependencies = [ "base64", "jni 0.21.1", diff --git a/Cargo.toml b/Cargo.toml index bd61ecfd..d8f1c747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,15 +96,6 @@ anyrender_vello = { path = "patches/anyrender_vello" } # handles the redraw itself. dioxus-native = { path = "patches/dioxus-native" } -[patch."https://github.com/appthere/loki-file-access"] -# PATCH: fixes two Android bugs — -# (1) ndk_context stores Application (not Activity); startActivityForResult needs -# the actual NativeActivity, obtained via AndroidApp::activity_as_ptr(). -# (2) ANativeActivityCallbacks has no onActivityResult; returns a clear error -# instead of hanging indefinitely, and exports the JNI hook for the future -# Gradle-based FilePickerActivity shim. -loki-file-access = { path = "patches/loki-file-access" } - # ── Build profiles ───────────────────────────────────────────────────────────── # # The unoptimized debug profile embeds full DWARF symbols for every dependency. diff --git a/android/.gitignore b/android/.gitignore index 176a7d7f..ff30202b 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -6,7 +6,8 @@ app/build/ # Staged by scripts/build-aab.sh from the canonical sources — not committed: # * native libraries built by cargo -# * FilePickerActivity.java copied from patches/loki-file-access/android/ +# * FilePickerActivity.java copied from the loki-file-access crate's +# android/ directory (resolved via cargo metadata) app/src/main/jniLibs/ app/src/main/java/ diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 68eca9ae..14426c53 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -104,7 +104,7 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | `blitz-net` 0.2.1 | upstream ships rustls by default / feature flag | Gated — not met | | `dioxus-native` 0.7.9 | upstream calls `request_redraw()` after head-element/event processing | Gated — not met | | `blitz-dom` 0.2.4 | upstream: tabindex focus-on-click, scroll dispatch, absolute node-scroll API, static-canvas anim fix | Gated — not met | -| **`loki-file-access` 0.1.2** | push fixes to the (same-team) `appthere/loki-file-access` repo, publish, repoint | **Actionable locally** — same-team-owned; no evidence pushed. Also needs Gradle `FilePickerActivity.kt`. | +| ~~**`loki-file-access` 0.1.2**~~ | push fixes to the (same-team) `appthere/loki-file-access` repo, publish, repoint | **REMOVED 2026-07-05** (plan Phase 2) — upstreamed as 0.1.3 (`d2b7bc5` on `main`), patch + vendored dir deleted, build scripts repointed via cargo metadata. 5 patches remain, all upstream-gated. | | ~~`fontique`~~ | — | Already removed 2026-06-21. | --- diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index b7be5bae..0d216bce 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -64,11 +64,11 @@ entirely in our hands. | Task | Detail | |---|---| -| 2.1 | Push the local patch fixes to the `appthere/loki-file-access` repo (including the Gradle `FilePickerActivity.kt` piece noted in `docs/patches.md`). | -| 2.2 | Publish the new version, repoint the workspace dependency, delete the `[patch]` entry, and update `docs/patches.md` (patch count 6 → 5). | +| 2.1 | ✅ **Done 2026-07-05** — full patch content (Android NativeActivity fixes, Java shims + dexing `build.rs`, insets/IME bridges, `token.delete()`/`copy_bytes_to()`) upstreamed to `appthere/loki-file-access` as **0.1.3**, commit `d2b7bc5` fast-forwarded to `main` (fix branch `claude/upstream-android-nativeactivity-fixes` also pushed). 43 tests + clippy clean upstream. | +| 2.2 | ✅ **Done 2026-07-05** — `[patch]` entry and `patches/loki-file-access/` removed; the `branch = "main"` git dependency resolves to 0.1.3 directly. The three Android build scripts now resolve the Java-shim directory from `cargo metadata` instead of the deleted patch path. Registry publication remains optional (the workspace dep is git, not registry) — noted in `docs/patches.md` "Removed patches". | -**Exit criterion**: `cargo build` with no `loki-file-access` patch and no -`Patch ... was not used` warnings. +**Exit criterion**: ✅ met — `cargo check --workspace` with no `loki-file-access` +patch and zero `Patch ... was not used` warnings. --- diff --git a/docs/patches.md b/docs/patches.md index 38d79048..e45546f5 100644 --- a/docs/patches.md +++ b/docs/patches.md @@ -380,107 +380,6 @@ is made synchronous (the `todo(jon)` comment in the original acknowledges this). --- -### loki-file-access — 0.1.2 - -**Source:** `patches/loki-file-access/` (local), vendored from the git source at -commit `176b590fb2da82b2ab278a15b34f0bea56ae0a7a` of -[appthere/loki-file-access](https://github.com/appthere/loki-file-access). - -**Fixes:** Two Android-specific bugs that caused a crash when tapping "Open -File" on a NativeActivity (cargo-apk) build: - -1. **Wrong activity reference for `startActivityForResult`.** android-activity - v0.6 intentionally stores the `Application` object (not the `Activity`) in - `ndk_context`, because `Application` outlives the Activity lifecycle. - `startActivityForResult` only exists on `Activity`, so calling it on the - `Application` object threw a `java.lang.NoSuchMethodError` / ART abort. The - patch adds `init_android(activity_as_ptr)` — called from `android_main` with - `AndroidApp::activity_as_ptr()` — which stores the actual NativeActivity - `GlobalRef` in an `AtomicPtr`. `start_activity_for_result` now - prefers this pointer over `ndk_context::android_context().context()`. - -2. **JNI exception not cleared on failure.** When `startActivityForResult` - failed (e.g., called on the wrong receiver type), a JNI exception was left - pending. The next `FindClass` JNI call made while an exception was pending - caused ART's checked-JNI mode to abort the process. The patch calls - `env.exception_clear()` when `call_method` returns an error. - -3. **Fail-fast for NativeActivity without Java shim.** `ANativeActivityCallbacks` - has no `onActivityResult` field — NDK NativeActivity can never receive - `startActivityForResult` results. Rather than hanging the async task - indefinitely, the patch returns `Err(PickerError::Platform)` immediately with - an explanatory message when the NativeActivity pointer is set but no Java - `FilePickerActivity` shim is registered. - -4. **Pre-wired JNI callback for future Gradle build.** The function - `Java_com_appthere_loki_FilePickerActivity_nativeOnResult` is exported from - the binary. Once a Gradle-based build includes `FilePickerActivity.kt` - (calling `nativeOnResult` from `onActivityResult`), end-to-end file picking - will work without further changes to this crate. - -**Also fixes:** `jni::errors::JniError` → `jni::errors::Error` in `jvm_err` and -`attach_err` helpers (the original used the wrong variant type for the jni -0.21.x API), and corrects a `#[no_mangle]` → `#[unsafe(no_mangle)]` attribute -for Rust 2024 edition compatibility. - -**Adds (PATCH(loki), 2026-06-13):** `query_window_insets_dp(activity_ptr)` — -orientation-aware safe-area insets `(top, bottom, left, right)` in dp from -`decorView.getRootWindowInsets().getInsets(systemBars | displayCutout)`. Unlike -the existing `query_insets_dp` (which reads the orientation-independent -`status_bar_height` / `navigation_bar_height` resources), this reflects the real -per-side insets, so landscape — where the navigation bar / cutout move to a side -— is padded correctly instead of keeping the portrait top/bottom values. Needs -the **Activity** (passed in via `AndroidApp::activity_as_ptr()`), since -`ndk_context` holds the `Application`, which has no window. Returns `None` -(caller falls back to `query_insets_dp`) before the view is attached or on -API < 30. Each Loki app (loki-text, loki-spreadsheet, loki-presentation) -re-queries it on resize via a hidden scroll-container sensor and pushes the -result into `appthere_ui::update_safe_area_insets`. - -**Extends (PATCH(loki), 2026-06-26):** the mask now also includes -`WindowInsets.Type.ime()`, i.e. -`getInsets(systemBars | displayCutout | ime)`. Because `getInsets` returns the -per-side union and `ime()` contributes only a bottom inset, top/left/right are -unchanged while the keyboard is hidden, and `bottom` grows to the soft-keyboard -height while it is visible. Combined with the blitz-shell IME-settle re-sync -(see the blitz-shell section), this is what reserves a bottom safe area for the -keyboard on a `NativeActivity` whose surface does not resize. `ime()` is API 30+ -— the same level as `getInsets(int)` — so the existing `None` fallback already -covers older API levels. - -**Adds (PATCH(loki), 2026-07-01):** `install_ime_listener(activity_ptr)` and -`set_ime_visibility_listener(cb)` — a soft-keyboard visibility signal. The former -loads the `ImeInsetsListener` Java shim (in `android/`) through the *application* -class loader (a native thread's default `FindClass` sees only framework classes), -binds its `nativeOnImeInsetsChanged` callback via `RegisterNatives` (so it does -not depend on the host `.so` name), and calls its static `install`, which sets a -decor-view `OnApplyWindowInsetsListener` on the UI thread. The listener reports -every IME visibility change — including the user-initiated dismiss/re-summon the -OS never surfaces to a `NativeActivity` — to the registered closure. loki-text -wires this to `blitz_shell::notify_ime_visibility_changed`. See the blitz-shell -"user-driven soft-keyboard collapse signal" note. The shim is dexed alongside -`FilePickerActivity` (build.rs / build-android.sh / build-aab.sh / -build-android.ps1) and, being JNI-referenced, requires minification to stay off -(already the case). - -**Root cause:** loki-file-access 0.1.2 was designed for desktop and WASM; the -Android implementation was scaffolded but never exercised on a real NativeActivity -build before this patch. - -**Upstream status:** The appthere/loki-file-access repository is maintained by -the same team. These fixes should be pushed upstream and the patch removed once -they are merged and a new version is published. - -**Removal condition:** Push these fixes to `appthere/loki-file-access`, publish -a new version, and update the workspace dependency to point at the registry -version. The `[patch."https://github.com/appthere/loki-file-access"]` entry and -the `patches/loki-file-access/` directory can then be removed. Full end-to-end -file picking additionally requires a Gradle build with `FilePickerActivity.kt`. - -**Added:** 2026-05-25 - ---- - ### blitz-dom — 0.2.4 **Source:** `patches/blitz-dom/` (local). @@ -730,6 +629,25 @@ Before removing a patch: ## Removed patches +### loki-file-access — removed 2026-07-05 (was 0.1.2) + +The `patches/loki-file-access` patch (added 2026-05-25) carried the Android +NativeActivity fixes — `init_android` Activity `GlobalRef` for +`startActivityForResult`, JNI exception clearing, the fail-fast for missing +`FilePickerActivity`, the `FilePickerActivity`/`ImeInsetsListener` Java shims ++ dexing `build.rs`, `query_window_insets_dp` / `install_ime_listener`, and +`FileAccessToken::delete()` / `copy_bytes_to()` — plus jni 0.21 error-type and +Rust-2024 `#[unsafe(no_mangle)]` fixes. + +Removed when the full patch content was upstreamed to +[appthere/loki-file-access](https://github.com/appthere/loki-file-access) as +**0.1.3** (commit `d2b7bc5`, fast-forwarded to `main`; the workspace's +`branch = "main"` git dependency now resolves to it directly). The crate is +not yet published to a registry — if it ever is, the git dependency can be +swapped for the registry version, but nothing requires that. Full end-to-end +Android file picking still requires a Gradle build that bundles the (now +upstream) `FilePickerActivity` shim. + ### fontique — removed 2026-06-21 (was 0.8.0) The `patches/fontique` patch (added 2026-04-13) worked around two issues with diff --git a/patches/loki-file-access/.cargo-ok b/patches/loki-file-access/.cargo-ok deleted file mode 100644 index 5f8b7958..00000000 --- a/patches/loki-file-access/.cargo-ok +++ /dev/null @@ -1 +0,0 @@ -{"v":1} \ No newline at end of file diff --git a/patches/loki-file-access/.cargo_vcs_info.json b/patches/loki-file-access/.cargo_vcs_info.json deleted file mode 100644 index 9c32ab5e..00000000 --- a/patches/loki-file-access/.cargo_vcs_info.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "git": { - "sha1": "f291e6550b254e2dca68ef0aed622cbc92dc0cbb" - }, - "path_in_vcs": "" -} \ No newline at end of file diff --git a/patches/loki-file-access/.gitignore b/patches/loki-file-access/.gitignore deleted file mode 100644 index ad679558..00000000 --- a/patches/loki-file-access/.gitignore +++ /dev/null @@ -1,21 +0,0 @@ -# Generated by Cargo -# will have compiled files and executables -debug -target - -# These are backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - -# Generated by cargo mutants -# Contains mutation testing data -**/mutants.out*/ - -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ diff --git a/patches/loki-file-access/Cargo.lock b/patches/loki-file-access/Cargo.lock deleted file mode 100644 index b54b2b15..00000000 --- a/patches/loki-file-access/Cargo.lock +++ /dev/null @@ -1,2185 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand", - "raw-window-handle", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cc" -version = "1.2.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "js-sys" -version = "0.3.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.184" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "loki-file-access" -version = "0.1.1" -dependencies = [ - "base64", - "jni", - "js-sys", - "ndk-context", - "objc2", - "objc2-foundation", - "objc2-ui-kit", - "pollster", - "rfd", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags", - "block2", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags", - "block2", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-core-text", - "objc2-foundation", - "objc2-quartz-core", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "rfd" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" -dependencies = [ - "ashpd", - "block2", - "dispatch2", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "pollster", - "raw-window-handle", - "urlencoding", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.10+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow 1.0.1", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.1", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.67" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.15", - "zvariant", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zvariant" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" -dependencies = [ - "endi", - "enumflags2", - "serde", - "url", - "winnow 0.7.15", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", - "winnow 0.7.15", -] diff --git a/patches/loki-file-access/Cargo.toml b/patches/loki-file-access/Cargo.toml deleted file mode 100644 index edc97071..00000000 --- a/patches/loki-file-access/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "loki-file-access" -version = "0.1.2" -edition = "2024" -license = "MIT" -description = "Cross-platform, frontend-agnostic file picker and capability-based file access for Rust" -repository = "https://github.com/appthere/loki-file-access" -keywords = ["file-picker", "android", "ios", "saf", "cross-platform"] -categories = ["filesystem", "os", "gui"] - -[features] -default = [] - -[dependencies] -thiserror = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -base64 = "0.22" -tracing = "0.1" - -[target.'cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))'.dependencies] -rfd = "0.15" - -[target.'cfg(target_os = "android")'.dependencies] -jni = "0.21" -ndk-context = "0.1" - -[target.'cfg(target_os = "ios")'.dependencies] -objc2 = "0.6" -objc2-ui-kit = { version = "0.3", features = ["UIDocumentPickerViewController", "UIViewController", "UIResponder"] } -objc2-foundation = { version = "0.3", features = ["NSURL", "NSData", "NSError"] } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = [ - "HtmlInputElement", "File", "FileList", "Event", - "EventTarget", "Document", "Window", "Blob", "Url" -] } -js-sys = "0.3" - -[dev-dependencies] -pollster = "0.4" -tempfile = "3" -serde_json = "1" -base64 = "0.22" - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] diff --git a/patches/loki-file-access/Cargo.toml.orig b/patches/loki-file-access/Cargo.toml.orig deleted file mode 100644 index 2408d9b2..00000000 --- a/patches/loki-file-access/Cargo.toml.orig +++ /dev/null @@ -1,49 +0,0 @@ -[package] -name = "loki-file-access" -version = "0.1.1" -edition = "2024" -license = "MIT" -description = "Cross-platform, frontend-agnostic file picker and capability-based file access for Rust" -repository = "https://github.com/appthere/loki-file-access" -keywords = ["file-picker", "android", "ios", "saf", "cross-platform"] -categories = ["filesystem", "os", "gui"] - -[features] -default = [] - -[dependencies] -thiserror = "2" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -base64 = "0.22" - -[target.'cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))'.dependencies] -rfd = "0.15" - -[target.'cfg(target_os = "android")'.dependencies] -jni = "0.21" -ndk-context = "0.1" - -[target.'cfg(target_os = "ios")'.dependencies] -objc2 = "0.6" -objc2-ui-kit = { version = "0.3", features = ["UIDocumentPickerViewController", "UIViewController", "UIResponder"] } -objc2-foundation = { version = "0.3", features = ["NSURL", "NSData", "NSError"] } - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = "0.2" -wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = [ - "HtmlInputElement", "File", "FileList", "Event", - "EventTarget", "Document", "Window", "Blob", "Url" -] } -js-sys = "0.3" - -[dev-dependencies] -pollster = "0.4" -tempfile = "3" -serde_json = "1" -base64 = "0.22" - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] diff --git a/patches/loki-file-access/LICENSE b/patches/loki-file-access/LICENSE deleted file mode 100644 index 0ae7426c..00000000 --- a/patches/loki-file-access/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 AppThere - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/patches/loki-file-access/android/FilePickerActivity.java b/patches/loki-file-access/android/FilePickerActivity.java deleted file mode 100644 index 5b6d8dfe..00000000 --- a/patches/loki-file-access/android/FilePickerActivity.java +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -package io.github.appthere.lokifileaccess; - -import android.app.Activity; -import android.content.ClipData; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; - -/** - * Transparent trampoline Activity for Android file picking via the Storage Access Framework. - * - *

{@code ANativeActivityCallbacks} has no {@code onActivityResult} callback, so - * NativeActivity cannot receive file-picker results directly. This Activity: - *

    - *
  1. Is started by NativeActivity via {@code startActivity} (no result needed).
  2. - *
  3. Calls {@code startActivityForResult(ACTION_OPEN_DOCUMENT / ACTION_CREATE_DOCUMENT)}.
  4. - *
  5. Receives the result in its own {@code onActivityResult}.
  6. - *
  7. Delivers the selected URI to the Rust future via {@code nativeOnResult}.
  8. - *
  9. Calls {@code finish()} to remove itself from the back stack.
  10. - *
- * - *

The native library is already loaded by NativeActivity before this Activity is - * ever created, so {@code System.loadLibrary} is not required here. - * - *

Register in {@code AndroidManifest.xml}: - *

{@code
- * 
- * }
- */ -public class FilePickerActivity extends Activity { - - static { - // Android 7+ scopes native-library JNI lookups to the class loader that - // called System.loadLibrary. NativeActivity loads libloki_text.so from - // the framework bootstrap class loader; FilePickerActivity runs under the - // application PathClassLoader. Without this call, nativeOnResult cannot - // be resolved and throws UnsatisfiedLinkError at runtime. - // Safe to call if the library is already loaded — ART returns immediately. - System.loadLibrary("loki_text"); - } - - private static final int REQUEST_OPEN = 1001; - private static final int REQUEST_CREATE = 1002; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - Intent src = getIntent(); - String mode = src.getStringExtra("mode"); - if ("CREATE".equals(mode)) { - launchCreate(src); - } else { - launchOpen(src); - } - } - - private void launchOpen(Intent src) { - Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - - // mime_types is passed as a comma-separated string to avoid JNI array complexity. - String mimeTypesRaw = src.getStringExtra("mime_types"); - if (mimeTypesRaw != null && !mimeTypesRaw.isEmpty()) { - String[] mimes = mimeTypesRaw.split(","); - if (mimes.length == 1) { - intent.setType(mimes[0]); - } else { - intent.setType("*/*"); - intent.putExtra(Intent.EXTRA_MIME_TYPES, mimes); - } - } else { - intent.setType("*/*"); - } - - boolean allowMultiple = src.getBooleanExtra("allow_multiple", false); - if (allowMultiple) { - intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - } - - startActivityForResult(intent, REQUEST_OPEN); - } - - private void launchCreate(Intent src) { - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - - String mimeType = src.getStringExtra("mime_type"); - intent.setType(mimeType != null ? mimeType : "*/*"); - - String suggestedName = src.getStringExtra("suggested_name"); - if (suggestedName != null) { - intent.putExtra(Intent.EXTRA_TITLE, suggestedName); - } - - startActivityForResult(intent, REQUEST_CREATE); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); - String result = null; - if (resultCode == RESULT_OK && data != null) { - // Multi-select delivers URIs through ClipData; single-select uses getData(). - // Join all URIs with '\n' so a single nativeOnResult call carries the full - // selection without requiring a JNI array parameter. - ClipData clip = data.getClipData(); - if (clip != null && clip.getItemCount() > 0) { - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < clip.getItemCount(); i++) { - Uri u = clip.getItemAt(i).getUri(); - if (u != null) { - if (sb.length() > 0) sb.append('\n'); - sb.append(u.toString()); - } - } - if (sb.length() > 0) result = sb.toString(); - } else { - Uri u = data.getData(); - if (u != null) result = u.toString(); - } - } - nativeOnResult(result); - finish(); - } - - /** - * Delivers the selected URI (or {@code null} for cancellation) to the pending Rust future. - * - *

Resolved via JNI against - * {@code Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult} - * in the host application's native library, which is already loaded by the time - * this Activity is created. - */ - private native void nativeOnResult(String uri); -} diff --git a/patches/loki-file-access/android/ImeInsetsListener.java b/patches/loki-file-access/android/ImeInsetsListener.java deleted file mode 100644 index 547c03ea..00000000 --- a/patches/loki-file-access/android/ImeInsetsListener.java +++ /dev/null @@ -1,63 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -package io.github.appthere.lokifileaccess; - -import android.app.Activity; -import android.os.Build; -import android.view.View; -import android.view.WindowInsets; - -/** - * Bridges Android soft-keyboard (IME) visibility changes to native code. - * - *

On a {@code NativeActivity} the OS never reports when the user dismisses or - * re-summons the soft keyboard (back button, swipe-down gesture, keyboard hide - * key), and the surface is not resized. This listener observes the decor view's - * window insets — which include the IME inset on API 30+ — and calls - * {@code nativeOnImeInsetsChanged} on every visibility transition so the Rust - * side can re-reserve (or release) the bottom safe area. - * - *

The native method is bound from Rust via {@code RegisterNatives}, so no - * {@code System.loadLibrary} is required here and the binding is independent of - * the host application's native-library name. - */ -public final class ImeInsetsListener implements View.OnApplyWindowInsetsListener { - - private boolean lastImeVisible; - - /** - * Install the listener on the activity's decor view. - * - *

Runs on the UI thread (View listeners must be set there) and is a no-op - * below API 30, where {@code WindowInsets.Type.ime()} / {@code isVisible} - * are unavailable — matching the query-side API-30 fallback in Rust. - */ - public static void install(final Activity activity) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - return; - } - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - View decor = activity.getWindow().getDecorView(); - decor.setOnApplyWindowInsetsListener(new ImeInsetsListener()); - // Kick an immediate inset dispatch so the initial state is known. - decor.requestApplyInsets(); - } - }); - } - - @Override - public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - boolean imeVisible = insets.isVisible(WindowInsets.Type.ime()); - if (imeVisible != lastImeVisible) { - lastImeVisible = imeVisible; - nativeOnImeInsetsChanged(imeVisible); - } - // Pass through so we neither consume nor alter the system's inset handling. - return v.onApplyWindowInsets(insets); - } - - private static native void nativeOnImeInsetsChanged(boolean imeVisible); -} diff --git a/patches/loki-file-access/build.rs b/patches/loki-file-access/build.rs deleted file mode 100644 index 21ce0ecb..00000000 --- a/patches/loki-file-access/build.rs +++ /dev/null @@ -1,192 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Build script — compiles the Android Java shims into `classes.dex` for -//! Android targets. -//! -//! Shims (in `android/`): -//! - `FilePickerActivity.java` — Storage Access Framework trampoline. -//! - `ImeInsetsListener.java` — soft-keyboard (IME) visibility bridge. -//! -//! Requires: -//! - `ANDROID_HOME` or `ANDROID_SDK_ROOT` pointing to the Android SDK -//! - `javac` on PATH, in `JAVA_HOME/bin`, or in Android Studio's bundled JDK -//! -//! On non-Android targets this script does nothing. - -use std::path::PathBuf; - -/// Java shim source files (relative to `android/`) compiled into the DEX. -const JAVA_SHIMS: &[&str] = &["FilePickerActivity.java", "ImeInsetsListener.java"]; - -fn main() { - for shim in JAVA_SHIMS { - println!("cargo:rerun-if-changed=android/{shim}"); - } - - let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - if target_os != "android" { - return; - } - - let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); - let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let java_srcs: Vec = JAVA_SHIMS - .iter() - .map(|shim| manifest_dir.join("android").join(shim)) - .collect(); - let dex_out = out_dir.join("classes.dex"); - - match compile_to_dex(&java_srcs, &out_dir, &dex_out) { - Ok(()) => { - println!("cargo:warning=Java shim DEX: {}", dex_out.display()); - println!("cargo:warning=Inject into APK with: scripts/build-android.ps1 -Install"); - } - Err(e) => { - println!("cargo:warning=Java shim compile skipped: {e}"); - println!("cargo:warning=Run scripts/build-android.ps1 which compiles the DEX itself."); - } - } - - println!("cargo:rustc-env=LOKI_FILE_ACCESS_DEX={}", dex_out.display()); -} - -fn compile_to_dex( - java_srcs: &[PathBuf], - out_dir: &std::path::Path, - dex_out: &std::path::Path, -) -> Result<(), String> { - let android_home = std::env::var("ANDROID_HOME") - .or_else(|_| std::env::var("ANDROID_SDK_ROOT")) - .map_err(|_| "ANDROID_HOME not set".to_owned())?; - let android_home = PathBuf::from(android_home); - - let android_jar = find_android_jar(&android_home)?; - let d8 = find_d8(&android_home)?; - let javac = find_javac(); - - let classes_dir = out_dir.join("java_classes"); - std::fs::create_dir_all(&classes_dir).map_err(|e| format!("mkdir classes: {e}"))?; - - let mut javac_args: Vec = vec![ - "-source".into(), - "8".into(), - "-target".into(), - "8".into(), - "-classpath".into(), - android_jar.to_str().unwrap().to_owned(), - "-d".into(), - classes_dir.to_str().unwrap().to_owned(), - ]; - javac_args.extend(java_srcs.iter().map(|p| p.to_str().unwrap().to_owned())); - - let status = std::process::Command::new(&javac) - .args(&javac_args) - .status() - .map_err(|e| format!("javac exec failed: {e}"))?; - if !status.success() { - return Err(format!("javac exited {status}")); - } - - // Collect every produced `.class` file (including inner classes such as the - // anonymous `Runnable` in `ImeInsetsListener`) so d8 dexes all of them. - let class_files = collect_class_files(&classes_dir); - if class_files.is_empty() { - return Err("no .class files produced by javac".to_owned()); - } - - let dex_dir = out_dir.join("dex_out"); - std::fs::create_dir_all(&dex_dir).map_err(|e| format!("mkdir dex: {e}"))?; - - let mut d8_args: Vec = class_files - .iter() - .map(|p| p.to_str().unwrap().to_owned()) - .collect(); - d8_args.push("--output".into()); - d8_args.push(dex_dir.to_str().unwrap().to_owned()); - d8_args.push("--min-api".into()); - d8_args.push("26".into()); - - let status = std::process::Command::new(&d8) - .args(&d8_args) - .status() - .map_err(|e| format!("d8 exec failed: {e}"))?; - if !status.success() { - return Err(format!("d8 exited {status}")); - } - - std::fs::copy(dex_dir.join("classes.dex"), dex_out).map_err(|e| format!("copy dex: {e}"))?; - - Ok(()) -} - -/// Recursively collect all `.class` files under `dir`. -fn collect_class_files(dir: &std::path::Path) -> Vec { - let mut out = Vec::new(); - let Ok(entries) = std::fs::read_dir(dir) else { - return out; - }; - for entry in entries.filter_map(|e| e.ok()) { - let path = entry.path(); - if path.is_dir() { - out.extend(collect_class_files(&path)); - } else if path.extension().is_some_and(|ext| ext == "class") { - out.push(path); - } - } - out -} - -fn find_android_jar(android_home: &std::path::Path) -> Result { - let platforms = android_home.join("platforms"); - for api in (26..=36).rev() { - let jar = platforms.join(format!("android-{api}")).join("android.jar"); - if jar.exists() { - return Ok(jar); - } - } - Err("android.jar not found under $ANDROID_HOME/platforms/android-*/".to_owned()) -} - -fn find_d8(android_home: &std::path::Path) -> Result { - let build_tools = android_home.join("build-tools"); - let mut entries: Vec<_> = std::fs::read_dir(&build_tools) - .map_err(|e| format!("read build-tools: {e}"))? - .filter_map(|e| e.ok()) - .collect(); - entries.sort_by(|a, b| b.file_name().cmp(&a.file_name())); - for entry in entries { - for name in &["d8.bat", "d8"] { - let d8 = entry.path().join(name); - if d8.exists() { - return Ok(d8); - } - } - } - Err("d8 not found under $ANDROID_HOME/build-tools/".to_owned()) -} - -fn find_javac() -> PathBuf { - if let Ok(java_home) = std::env::var("JAVA_HOME") { - for name in &["bin/javac", "bin/javac.exe"] { - let p = PathBuf::from(&java_home).join(name); - if p.exists() { - return p; - } - } - } - // Android Studio bundled JDK (Windows — Program Files location) - #[cfg(target_os = "windows")] - { - let pf = std::env::var("PROGRAMFILES").unwrap_or_else(|_| "C:\\Program Files".into()); - let p = PathBuf::from(pf).join("Android\\Android Studio\\jbr\\bin\\javac.exe"); - if p.exists() { - return p; - } - } - PathBuf::from(if cfg!(target_os = "windows") { - "javac.exe" - } else { - "javac" - }) -} diff --git a/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md b/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md deleted file mode 100644 index 784b7324..00000000 --- a/patches/loki-file-access/docs/adr/0001-runtime-agnostic-future.md +++ /dev/null @@ -1,58 +0,0 @@ -# ADR 0001: Runtime-Agnostic Future Implementation - -## Status - -Accepted - -## Context - -`loki-file-access` must present native file-picker dialogs and return the -result as a Rust `Future`. The crate targets a wide range of consumers: -Dioxus, egui, Iced, Xilem, and applications using `pollster::block_on` for -synchronous contexts. - -Each of these frameworks has its own executor (or uses none at all). Tying -the crate to a specific async runtime would force every consumer to depend on -that runtime, even if they never use it elsewhere. - -## Decision - -Implement a minimal one-shot future (`PickFuture`) using only `std` -primitives: - -- `Arc>>` holds the shared state. -- `PickState` contains an `Option` for the result and an - `Option` for the executor's waker. -- The `Future` implementation checks for a result on each `poll`, stores the - waker if pending, and returns `Poll::Ready` once a result is delivered. -- A `deliver()` function is called from the platform callback (JNI, Objective-C - delegate, JS event listener) to set the result and wake the executor. - -No Tokio, async-std, or other runtime crate is required. - -## Consequences - -### Positive - -- **Universal compatibility**: Works with any executor, including - `pollster::block_on`, Tokio, async-std, smol, and custom executors. -- **Zero runtime dependencies**: The crate's dependency footprint is minimal. -- **Simple mental model**: One future, one result, one waker — easy to audit. - -### Negative - -- **Slightly more code** than using `tokio::sync::oneshot` or a similar - runtime-provided channel. -- **No built-in timeout**: Consumers must implement their own timeout logic - if needed (e.g. `tokio::time::timeout` wrapping the future). -- **Mutex overhead**: Each `poll` and `deliver` call acquires a mutex lock. - This is negligible for file-picker operations (user-driven, infrequent). - -## Alternatives Rejected - -| Alternative | Reason for rejection | -|---|---| -| `tokio::sync::oneshot` | Adds a mandatory Tokio dependency. Consumers using other runtimes or `pollster` would be forced to pull in Tokio. | -| `async_channel` | Additional dependency for a single-use channel. The crate only needs one-shot delivery, not a full channel. | -| `flume` | Additional dependency. Same reasoning as `async_channel` — the problem is simpler than what `flume` solves. | -| `futures::channel::oneshot` | Adds the `futures` crate as a dependency. While lighter than Tokio, it is still an unnecessary dependency for this use case. | diff --git a/patches/loki-file-access/docs/adr/0002-mit-license.md b/patches/loki-file-access/docs/adr/0002-mit-license.md deleted file mode 100644 index 041aff34..00000000 --- a/patches/loki-file-access/docs/adr/0002-mit-license.md +++ /dev/null @@ -1,58 +0,0 @@ -# ADR 0002: MIT License - -## Status - -Accepted - -## Context - -`loki-file-access` is a general-purpose utility crate for cross-platform file -access. It is intended for broad adoption across the Rust ecosystem, including -use in projects under a variety of open-source and proprietary licenses. - -The two most common choices for Rust crates are MIT and Apache-2.0 (often -dual-licensed as `MIT OR Apache-2.0`). A license decision is needed. - -## Decision - -Use the **MIT license** exclusively (not dual-licensed with Apache-2.0). - -## Rationale - -- **Maximum compatibility**: MIT is compatible with a strictly wider range of - downstream projects than Apache-2.0. Some projects and organisations cannot - accept Apache-2.0 due to concerns about the patent clause (Section 3) — for - example, projects under GPLv2-only or certain corporate policies. MIT has - no patent clause, removing this friction. - -- **No strategic IP**: As a low-level utility crate with no novel algorithms or - patentable inventions, the patent protection offered by Apache-2.0 provides - negligible benefit to the project or its contributors. - -- **Simplicity**: A single license is easier to understand, audit, and comply - with than a dual-license arrangement. Downstream consumers do not need to - choose between two options or evaluate which one to apply. - -- **Ecosystem norms**: Many widely-used Rust crates (serde, rand, base64, etc.) - are MIT-licensed. Using the same license reduces cognitive overhead for - consumers evaluating dependency licenses. - -## Consequences - -### Positive - -- Downstream projects under **any** OSI-approved license can use this crate - without restriction. -- Contributors do not need to agree to a CLA or patent grant beyond what MIT - already provides. -- License compliance is straightforward: include the copyright notice and - permission notice. - -### Negative - -- No explicit patent grant from contributors. If a contributor holds a patent - that reads on the crate's functionality, MIT alone does not provide an - express license to those patent claims. This is an acceptable risk given the - crate's nature as a utility library. -- Cannot be relicensed to Apache-2.0 later without consent from all copyright - holders (though this is unlikely to be needed). diff --git a/patches/loki-file-access/src/api.rs b/patches/loki-file-access/src/api.rs deleted file mode 100644 index 54850aa1..00000000 --- a/patches/loki-file-access/src/api.rs +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Public API surface for presenting file-picker dialogs. -//! -//! This module defines [`FilePicker`], [`PickOptions`], and [`SaveOptions`] — -//! the primary entry points for all file-picker operations. Platform-specific -//! behaviour is fully abstracted behind these types. - -use crate::error::PickerError; -use crate::token::FileAccessToken; - -/// Options for opening an existing file via a platform file-picker dialog. -/// -/// # Examples -/// -/// ``` -/// use loki_file_access::PickOptions; -/// -/// let opts = PickOptions { -/// mime_types: vec!["image/png".into(), "image/jpeg".into()], -/// filter_label: Some("Images".into()), -/// multi: false, -/// }; -/// ``` -#[derive(Debug, Clone, Default)] -pub struct PickOptions { - /// MIME types to filter in the picker dialog. - /// - /// An empty vector means all file types are shown. - pub mime_types: Vec, - - /// Display label for the file-type filter in the picker UI. - /// - /// Not all platforms support this (e.g. Android ignores it). - pub filter_label: Option, - - /// Whether the user may select multiple files. - /// - /// When `false`, at most one file is returned. - pub multi: bool, -} - -/// Options for saving a new file or overwriting an existing one. -/// -/// # Examples -/// -/// ``` -/// use loki_file_access::SaveOptions; -/// -/// let opts = SaveOptions { -/// mime_type: Some("text/plain".into()), -/// suggested_name: Some("notes.txt".into()), -/// }; -/// ``` -#[derive(Debug, Clone, Default)] -pub struct SaveOptions { - /// The MIME type of the file being saved. - /// - /// Used by some platforms (Android SAF) to pre-filter the save location. - pub mime_type: Option, - - /// Suggested filename including extension. - /// - /// The user may change this in the save dialog. - pub suggested_name: Option, -} - -/// Frontend-agnostic file picker that delegates to the native platform dialog. -/// -/// `FilePicker` has no state and is cheap to construct. All methods return -/// standard [`Future`] values that can be awaited from any async runtime, -/// including `pollster::block_on` for synchronous contexts. -/// -/// # Examples -/// -/// ```no_run -/// use loki_file_access::{FilePicker, PickOptions}; -/// -/// # async fn example() -> Result<(), Box> { -/// let picker = FilePicker::new(); -/// let token = picker -/// .pick_file_to_open(PickOptions::default()) -/// .await?; -/// -/// if let Some(token) = token { -/// println!("Selected: {}", token.display_name()); -/// } -/// # Ok(()) -/// # } -/// ``` -#[derive(Debug, Clone, Default)] -pub struct FilePicker; - -impl FilePicker { - /// Create a new `FilePicker` instance. - #[must_use] - pub fn new() -> Self { - Self - } - - /// Present a platform dialog for the user to select a single file. - /// - /// Returns `Ok(Some(token))` if the user selected a file, or `Ok(None)` - /// if the user cancelled the dialog. The `multi` field of `options` is - /// ignored — use [`pick_files_to_open`](Self::pick_files_to_open) for - /// multi-selection. - /// - /// # Errors - /// - /// Returns [`PickerError`] if the platform dialog could not be presented. - #[must_use = "this returns a Result that may contain an error"] - pub async fn pick_file_to_open( - &self, - options: PickOptions, - ) -> Result, PickerError> { - let opts = PickOptions { - multi: false, - ..options - }; - crate::platform::pick_open_single(opts).await - } - - /// Present a platform dialog for the user to select multiple files. - /// - /// Returns a (possibly empty) vector of tokens. An empty vector means - /// the user cancelled the dialog. The `multi` field of `options` is - /// forced to `true`. - /// - /// # Errors - /// - /// Returns [`PickerError`] if the platform dialog could not be presented. - #[must_use = "this returns a Result that may contain an error"] - pub async fn pick_files_to_open( - &self, - options: PickOptions, - ) -> Result, PickerError> { - let opts = PickOptions { - multi: true, - ..options - }; - crate::platform::pick_open_multi(opts).await - } - - /// Present a platform dialog for the user to choose a save location. - /// - /// Returns `Ok(Some(token))` if the user confirmed a save location, or - /// `Ok(None)` if the user cancelled. - /// - /// # Platform notes - /// - /// On WASM, this triggers a browser download via a Blob URL rather than - /// presenting a traditional save dialog. The returned token wraps an - /// in-memory buffer. - /// - /// # Errors - /// - /// Returns [`PickerError`] if the platform dialog could not be presented. - #[must_use = "this returns a Result that may contain an error"] - pub async fn pick_file_to_save( - &self, - options: SaveOptions, - ) -> Result, PickerError> { - crate::platform::pick_save(options).await - } -} diff --git a/patches/loki-file-access/src/error.rs b/patches/loki-file-access/src/error.rs deleted file mode 100644 index 4977b04e..00000000 --- a/patches/loki-file-access/src/error.rs +++ /dev/null @@ -1,209 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Error types for the `loki-file-access` crate. -//! -//! This module defines all error enums used across the public API surface: -//! -//! - [`PickerError`] — errors originating from the platform file-picker dialog. -//! - [`AccessError`] — errors when reading from or writing to a previously granted file. -//! - [`TokenParseError`] — errors when deserializing a stored [`crate::FileAccessToken`]. -//! -//! All enums are `#[non_exhaustive]` so that new variants can be added in -//! future minor versions without breaking downstream matches. - -/// Errors that can occur when presenting a file-picker dialog. -/// -/// Note that the user cancelling the dialog is **not** an error — it is -/// represented as `Ok(None)` on the single-file methods and `Ok(vec![])` on -/// multi-file methods. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum PickerError { - /// The platform returned an error from the native file-picker API. - #[error("platform file-picker error: {message}")] - Platform { - /// Human-readable description of the platform error. - message: String, - }, - - /// The current platform does not support the requested operation. - #[error("operation not supported on this platform: {operation}")] - Unsupported { - /// Description of the unsupported operation. - operation: String, - }, - - /// An internal synchronisation error occurred (e.g. a poisoned mutex). - #[error("internal synchronisation error: {message}")] - Internal { - /// Human-readable description of the internal error. - message: String, - }, -} - -/// Errors that can occur when opening or accessing a previously granted file. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum AccessError { - /// The permission grant for this file has been revoked by the user or OS. - #[error("file access permission has been revoked")] - PermissionRevoked, - - /// An I/O error occurred while reading from or writing to the file. - #[error("I/O error: {source}")] - Io { - /// The underlying I/O error. - #[from] - source: std::io::Error, - }, - - /// The file descriptor or handle returned by the platform was invalid. - #[error("invalid file descriptor returned by platform")] - InvalidDescriptor, - - /// The platform returned an error when attempting to open the file. - #[error("platform access error: {message}")] - Platform { - /// Human-readable description of the platform error. - message: String, - }, - - /// The requested operation is not supported on the current platform. - #[error("operation not supported on this platform: {operation}")] - Unsupported { - /// Description of the unsupported operation. - operation: String, - }, -} - -/// Errors that can occur when deserializing a stored [`crate::FileAccessToken`]. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum TokenParseError { - /// The base64 encoding of the token is invalid. - #[error("invalid base64 encoding: {message}")] - InvalidBase64 { - /// Description of the base64 decode error. - message: String, - }, - - /// The JSON payload inside the token is malformed. - #[error("invalid JSON in token: {message}")] - InvalidJson { - /// Description of the JSON parse error. - message: String, - }, - - /// The token contains an unrecognised platform variant. - #[error("unknown token variant: {variant}")] - UnknownVariant { - /// The variant tag that was not recognised. - variant: String, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn picker_error_platform_displays_message() { - let err = PickerError::Platform { - message: "dialog failed".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty(), "display string must not be empty"); - assert!(msg.contains("dialog failed")); - } - - #[test] - fn picker_error_unsupported_displays_message() { - let err = PickerError::Unsupported { - operation: "save".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("save")); - } - - #[test] - fn picker_error_internal_displays_message() { - let err = PickerError::Internal { - message: "mutex poisoned".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("mutex poisoned")); - } - - #[test] - fn access_error_permission_revoked_displays_message() { - let err = AccessError::PermissionRevoked; - assert!(!err.to_string().is_empty()); - } - - #[test] - fn access_error_io_displays_message() { - let err = AccessError::Io { - source: std::io::Error::new(std::io::ErrorKind::NotFound, "gone"), - }; - assert!(!err.to_string().is_empty()); - } - - #[test] - fn access_error_invalid_descriptor_displays_message() { - let err = AccessError::InvalidDescriptor; - assert!(!err.to_string().is_empty()); - } - - #[test] - fn access_error_platform_displays_message() { - let err = AccessError::Platform { - message: "fd error".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("fd error")); - } - - #[test] - fn access_error_unsupported_displays_message() { - let err = AccessError::Unsupported { - operation: "delete".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("delete")); - } - - #[test] - fn token_parse_error_base64_displays_message() { - let err = TokenParseError::InvalidBase64 { - message: "bad padding".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("bad padding")); - } - - #[test] - fn token_parse_error_json_displays_message() { - let err = TokenParseError::InvalidJson { - message: "unexpected EOF".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("unexpected EOF")); - } - - #[test] - fn token_parse_error_unknown_variant_displays_message() { - let err = TokenParseError::UnknownVariant { - variant: "FuturePlatform".into(), - }; - let msg = err.to_string(); - assert!(!msg.is_empty()); - assert!(msg.contains("FuturePlatform")); - } -} diff --git a/patches/loki-file-access/src/future.rs b/patches/loki-file-access/src/future.rs deleted file mode 100644 index cfe6b500..00000000 --- a/patches/loki-file-access/src/future.rs +++ /dev/null @@ -1,182 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Runtime-agnostic one-shot future for delivering file-picker results. -//! -//! [`PickFuture`] and [`PickState`] implement a simple single-value future -//! using only `std` primitives (`Arc`, `Mutex`, `Waker`). This avoids any -//! dependency on Tokio, async-std, or other async runtimes, making the crate -//! usable from **any** executor — including `pollster::block_on`, Dioxus, -//! egui, Iced, and Xilem. -//! -//! # Usage (crate-internal) -//! -//! 1. Create a shared `Arc>>`. -//! 2. Return a `PickFuture` wrapping that state to the caller. -//! 3. From the platform callback, call [`deliver`] with the result value. - -use std::pin::Pin; -use std::sync::{Arc, Mutex}; -use std::task::{Context, Poll, Waker}; - -/// Shared state between the [`PickFuture`] and the platform callback that -/// delivers the result. -// Used by non-desktop platforms (Android, iOS) that drive callbacks through -// `deliver()`. On desktop the `rfd` crate's own async API is used instead. -#[allow(dead_code)] -pub(crate) struct PickState { - /// The result value, set exactly once by [`deliver`]. - pub result: Option, - /// The most recent [`Waker`] registered by the executor. - pub waker: Option, -} - -/// A future that resolves to a single value of type `T`. -/// -/// This is the core async primitive used by all platform picker -/// implementations to bridge callback-based native APIs into Rust futures. -// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. -#[allow(dead_code)] -#[must_use = "futures do nothing unless polled"] -pub(crate) struct PickFuture { - /// Shared state with the callback side. - pub state: Arc>>, -} - -impl Future for PickFuture { - type Output = T; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let mut guard = match self.state.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - - if let Some(value) = guard.result.take() { - return Poll::Ready(value); - } - - guard.waker = Some(cx.waker().clone()); - Poll::Pending - } -} - -/// Deliver a result value to a [`PickFuture`] and wake the executor. -/// -/// This function is called from platform callbacks (JNI, Objective-C delegates, -/// JS event listeners, etc.) to complete the associated future. -/// -/// # Panics -/// -/// This function does not panic. If the mutex is poisoned, it recovers the -/// inner state and proceeds normally. -// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. -#[allow(dead_code)] -pub(crate) fn deliver(state: &Arc>>, value: T) { - let waker = { - let mut guard = match state.lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - guard.result = Some(value); - guard.waker.take() - }; - - if let Some(w) = waker { - w.wake(); - } -} - -/// Create a new `(PickFuture, Arc>>)` pair. -/// -/// The returned `Arc` should be passed to the platform callback side, while -/// the `PickFuture` is returned to the caller to be awaited. -// Used by non-desktop platforms; on desktop, `rfd`'s own async API suffices. -#[allow(dead_code)] -pub(crate) fn new_pick_future() -> (PickFuture, Arc>>) { - let state = Arc::new(Mutex::new(PickState { - result: None, - waker: None, - })); - let future = PickFuture { - state: Arc::clone(&state), - }; - (future, state) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::task::{RawWaker, RawWakerVTable}; - - /// Create a no-op waker for testing poll behaviour without an executor. - fn noop_waker() -> Waker { - fn noop(_: *const ()) {} - fn clone(p: *const ()) -> RawWaker { - RawWaker::new(p, &VTABLE) - } - const VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop); - // SAFETY: The noop waker functions are trivially safe — they perform - // no operations on the data pointer. - unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } - } - - #[test] - fn future_returns_pending_before_delivery() { - let (mut future, _state) = new_pick_future::(); - let waker = noop_waker(); - let mut cx = Context::from_waker(&waker); - let pinned = Pin::new(&mut future); - assert!( - pinned.poll(&mut cx).is_pending(), - "future should be Pending before deliver()" - ); - } - - #[test] - fn future_returns_ready_after_delivery() { - let (future, state) = new_pick_future::(); - deliver(&state, 42); - let result = pollster::block_on(future); - assert_eq!(result, 42); - } - - #[test] - fn future_returns_ready_after_delivery_with_pollster() { - let (future, state) = new_pick_future::(); - deliver(&state, "hello".to_owned()); - let result = pollster::block_on(future); - assert_eq!(result, "hello"); - } - - #[test] - fn deliver_wakes_the_waker() { - use std::sync::atomic::{AtomicBool, Ordering}; - - static WOKEN: AtomicBool = AtomicBool::new(false); - - fn noop(_: *const ()) {} - fn wake(_: *const ()) { - WOKEN.store(true, Ordering::SeqCst); - } - fn clone_fn(p: *const ()) -> RawWaker { - RawWaker::new(p, &WAKE_VTABLE) - } - const WAKE_VTABLE: RawWakerVTable = RawWakerVTable::new(clone_fn, wake, wake, noop); - - WOKEN.store(false, Ordering::SeqCst); - - let (mut future, state) = new_pick_future::(); - - // SAFETY: The custom waker functions are trivially safe. - let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &WAKE_VTABLE)) }; - let mut cx = Context::from_waker(&waker); - let _ = Pin::new(&mut future).poll(&mut cx); - - deliver(&state, 99); - assert!( - WOKEN.load(Ordering::SeqCst), - "waker should have been called" - ); - } -} diff --git a/patches/loki-file-access/src/lib.rs b/patches/loki-file-access/src/lib.rs deleted file mode 100644 index aaf6345c..00000000 --- a/patches/loki-file-access/src/lib.rs +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! # loki-file-access -//! -//! Cross-platform, frontend-agnostic file picker and capability-based file -//! access for Rust applications. -//! -//! This crate provides a unified API for presenting native file-picker dialogs -//! and accessing user-selected files across all major platforms: -//! -//! - **Desktop** (Windows, macOS, Linux, BSD) — via the [`rfd`](https://crates.io/crates/rfd) crate -//! - **Android** — via the Storage Access Framework with persistable URI permissions -//! - **iOS** — via `UIDocumentPickerViewController` with security-scoped bookmarks -//! - **WASM** — via `` with in-memory file buffers -//! -//! # Zero UI Framework Dependencies -//! -//! `loki-file-access` has **no UI framework dependencies**. It returns -//! standard [`Future`] values implemented with only `std` primitives (no Tokio, -//! no async-std required). It is usable from Dioxus, egui, Iced, Xilem, -//! `pollster::block_on`, or any other async or sync Rust context. -//! -//! # Quick Start -//! -//! ```no_run -//! use loki_file_access::{FilePicker, PickOptions}; -//! use std::io::Read; -//! -//! # async fn example() -> Result<(), Box> { -//! let picker = FilePicker::new(); -//! -//! // Pick a file to open -//! let token = picker -//! .pick_file_to_open(PickOptions { -//! mime_types: vec!["text/plain".into()], -//! ..Default::default() -//! }) -//! .await?; -//! -//! if let Some(token) = token { -//! let mut reader = token.open_read()?; -//! let mut contents = String::new(); -//! reader.read_to_string(&mut contents)?; -//! println!("File contents: {contents}"); -//! -//! // Serialize the token for later use -//! let stored = token.serialize(); -//! println!("Token: {stored}"); -//! } -//! # Ok(()) -//! # } -//! ``` -//! -//! # Capability Tokens -//! -//! Every picker operation returns a [`FileAccessToken`] — a serializable -//! capability that encapsulates all platform-specific state needed to re-open -//! the file. Tokens can be serialized to a URL-safe string for storage in a -//! recent-files list and deserialized to re-open files across app restarts. -//! -//! On Android, the token holds a content URI with a persistable permission -//! grant. On iOS, it holds a security-scoped bookmark. On desktop, it holds -//! a filesystem path. On WASM, it holds the file data in memory. - -pub mod api; -pub mod error; -pub(crate) mod future; -mod platform; -pub mod token; - -// Re-export public types at crate root for convenience. -pub use api::{FilePicker, PickOptions, SaveOptions}; -pub use error::{AccessError, PickerError, TokenParseError}; -pub use token::{FileAccessToken, PermissionStatus, ReadSeek, WriteSeek}; - -#[cfg(target_os = "android")] -pub use platform::{ - init_android, install_ime_listener, on_activity_result, query_insets_dp, query_window_insets_dp, - set_ime_visibility_listener, -}; diff --git a/patches/loki-file-access/src/platform/android/jni_activity.rs b/patches/loki-file-access/src/platform/android/jni_activity.rs deleted file mode 100644 index e64ceb6d..00000000 --- a/patches/loki-file-access/src/platform/android/jni_activity.rs +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! JNI helpers for launching `FilePickerActivity`. -//! -//! `FilePickerActivity` is a thin Java trampoline that works around the -//! `ANativeActivityCallbacks` limitation: NativeActivity has no -//! `onActivityResult` slot, but a plain `Activity` subclass does. -//! -//! Flow: -//! 1. NativeActivity calls `startActivity(Intent → FilePickerActivity)`. -//! 2. `FilePickerActivity.onCreate` calls `startActivityForResult(ACTION_OPEN_*)`. -//! 3. `FilePickerActivity.onActivityResult` receives the URI and calls -//! `nativeOnResult(uri)` — the pre-compiled JNI hook in the Rust binary. -//! 4. The Rust future resolves. - -use super::jni_common::{attach_err, jvm_err, platform_err}; -use crate::api::{PickOptions, SaveOptions}; -use crate::error::PickerError; - -// Fully-qualified Java class name for FilePickerActivity. -// NOTE: do NOT use this as a ComponentName package — that field identifies the -// APK, not the Java package. Use Intent.setClassName(Context, String) instead -// so the runtime APK package name is derived from the Application context. -const FPA_CLASS: &str = "io.github.appthere.lokifileaccess.FilePickerActivity"; - -// ── Public entry points ─────────────────────────────────────────────────────── - -/// Start `FilePickerActivity` to open one or more files. -pub(super) fn fire_open_file_picker( - options: &PickOptions, - allow_multiple: bool, -) -> Result<(), PickerError> { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; - let mut env = vm.attach_current_thread().map_err(attach_err)?; - - let intent = build_fpa_intent(&mut env, "OPEN")?; - - let mimes = options.mime_types.join(","); - put_string_extra(&mut env, &intent, "mime_types", &mimes)?; - - // putExtra(String, boolean) — JNI signature: (Ljava/lang/String;Z) - let key = env - .new_string("allow_multiple") - .map_err(|e| platform_err("allow_multiple key", e))?; - env.call_method( - &intent, - "putExtra", - "(Ljava/lang/String;Z)Landroid/content/Intent;", - &[ - jni::objects::JValueGen::Object(&key), - jni::objects::JValueGen::Bool(u8::from(allow_multiple)), - ], - ) - .map_err(|e| platform_err("putExtra allow_multiple", e))?; - - start_activity(&mut env, &intent) -} - -/// Start `FilePickerActivity` to create/save a file. -pub(super) fn fire_create_file_picker( - options: &SaveOptions, -) -> Result<(), PickerError> { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; - let mut env = vm.attach_current_thread().map_err(attach_err)?; - - let intent = build_fpa_intent(&mut env, "CREATE")?; - - if let Some(ref mime) = options.mime_type { - put_string_extra(&mut env, &intent, "mime_type", mime)?; - } - if let Some(ref name) = options.suggested_name { - put_string_extra(&mut env, &intent, "suggested_name", name)?; - } - - start_activity(&mut env, &intent) -} - -// ── Private helpers ─────────────────────────────────────────────────────────── - -/// Build an explicit `Intent` targeting `FilePickerActivity` with a `mode` extra. -/// -/// Uses `Intent.setClassName(Context, String)` rather than constructing a -/// `ComponentName` directly. `ComponentName(pkg, cls)` uses `pkg` as the -/// *application* identifier (the APK package name), not the Java package. -/// Hardcoding the Java package `io.github.appthere.lokifileaccess` would cause -/// Android to return `START_CLASS_NOT_FOUND` (-92) because no installed APK has -/// that package name. `setClassName(Context, cls)` derives the package from the -/// Application context at runtime, correctly targeting this APK regardless of -/// what package name the host app uses. -fn build_fpa_intent<'a>( - env: &mut jni::JNIEnv<'a>, - mode: &str, -) -> Result, PickerError> { - // new Intent() - let intent_cls = env - .find_class("android/content/Intent") - .map_err(|e| platform_err("Intent class", e))?; - let intent = env - .new_object(&intent_cls, "()V", &[]) - .map_err(|e| platform_err("Intent()", e))?; - - // intent.setClassName(context, FPA_CLASS) — resolves the APK package from - // the Application context so the component is found in this app's process. - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the Application jobject initialised before android_main. - let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - let cls_str = env - .new_string(FPA_CLASS) - .map_err(|e| platform_err("fpa class string", e))?; - env.call_method( - &intent, - "setClassName", - "(Landroid/content/Context;Ljava/lang/String;)Landroid/content/Intent;", - &[ - jni::objects::JValueGen::Object(&context), - jni::objects::JValueGen::Object(&cls_str), - ], - ) - .map_err(|e| platform_err("setClassName", e))?; - - // intent.putExtra("mode", mode) - put_string_extra(env, &intent, "mode", mode)?; - - Ok(intent) -} - -/// Add a `String` extra to an `Intent`. -fn put_string_extra( - env: &mut jni::JNIEnv<'_>, - intent: &jni::objects::JObject<'_>, - key: &str, - value: &str, -) -> Result<(), PickerError> { - let k = env.new_string(key).map_err(|e| platform_err("extra key", e))?; - let v = env.new_string(value).map_err(|e| platform_err("extra value", e))?; - env.call_method( - intent, - "putExtra", - "(Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;", - &[ - jni::objects::JValueGen::Object(&k), - jni::objects::JValueGen::Object(&v), - ], - ) - .map_err(|e| platform_err("putExtra string", e))?; - Ok(()) -} - -/// Call `Context.startActivity(intent)` using the Application context. -/// -/// `ndk_context` provides the Application object set by android-activity before -/// `android_main` is called. Starting an Activity from a non-Activity context -/// requires `FLAG_ACTIVITY_NEW_TASK`, which is added to the intent here. -/// -/// `FilePickerActivity` is a transparent trampoline: it receives its own -/// `onActivityResult` from the SAF picker (within its own task) and delivers -/// the URI to Rust via `nativeOnResult`. No result needs to flow back to -/// NativeActivity, so the new-task restriction is not a problem. -fn start_activity( - env: &mut jni::JNIEnv<'_>, - intent: &jni::objects::JObject<'_>, -) -> Result<(), PickerError> { - // FLAG_ACTIVITY_NEW_TASK — required when calling startActivity from a - // non-Activity Context such as Application. - const FLAG_ACTIVITY_NEW_TASK: i32 = 0x1000_0000; - let flags_result = env.call_method( - intent, - "addFlags", - "(I)Landroid/content/Intent;", - &[jni::objects::JValueGen::Int(FLAG_ACTIVITY_NEW_TASK)], - ); - if let Err(e) = flags_result { - // Clear any pending JNI exception before returning so subsequent - // JNI calls on this env do not trigger an ART abort. - let _ = env.exception_clear(); - return Err(platform_err("addFlags", e)); - } - - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the Application jobject initialised by - // android-activity before android_main runs. Valid for the process lifetime. - let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - - let result = env.call_method( - &context, - "startActivity", - "(Landroid/content/Intent;)V", - &[jni::objects::JValueGen::Object(intent)], - ); - - if result.is_err() { - // Clear any pending JNI exception (e.g. ActivityNotFoundException) so - // subsequent JNI calls in this env do not trigger an ART abort. - let _ = env.exception_clear(); - } - - result.map(|_| ()).map_err(|e| platform_err("startActivity", e)) -} diff --git a/patches/loki-file-access/src/platform/android/jni_common.rs b/patches/loki-file-access/src/platform/android/jni_common.rs deleted file mode 100644 index ebe01da7..00000000 --- a/patches/loki-file-access/src/platform/android/jni_common.rs +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Shared JNI error helpers used by the Android platform modules. -//! -//! Extracted here so both [`super::jni_intents`] and [`super::jni_activity`] -//! can import without circular dependencies. - -use crate::error::PickerError; - -// ── Error conversions ───────────────────────────────────────────────────────── - -pub(super) fn jvm_err(e: jni::errors::Error) -> PickerError { - PickerError::Platform { - message: format!("failed to get JavaVM: {e}"), - } -} - -pub(super) fn attach_err(e: jni::errors::Error) -> PickerError { - PickerError::Platform { - message: format!("failed to attach JNI thread: {e}"), - } -} - -pub(super) fn platform_err(ctx: &str, e: impl std::fmt::Display) -> PickerError { - PickerError::Platform { - message: format!("{ctx}: {e}"), - } -} diff --git a/patches/loki-file-access/src/platform/android/jni_fd.rs b/patches/loki-file-access/src/platform/android/jni_fd.rs deleted file mode 100644 index 1d94214c..00000000 --- a/patches/loki-file-access/src/platform/android/jni_fd.rs +++ /dev/null @@ -1,150 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Android file-descriptor and permission-checking JNI helpers. -//! -//! Split from [`super::jni_intents`] to keep each file under 300 lines. - -use super::jni_common::{attach_err, jvm_err, platform_err}; -use crate::error::{AccessError, PickerError}; -use crate::token::PermissionStatus; - -/// Query `ContentResolver.getPersistedUriPermissions()` for a URI. -pub(in crate::platform) fn check_persisted_permission( - uri: &str, -) -> Result { - let ctx = ndk_context::android_context(); - let vm = - unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; - let mut env = vm - .attach_current_thread() - .map_err(attach_err)?; - - let resolver = super::jni_intents::get_content_resolver(&mut env, &ctx)?; - - let list = env - .call_method( - &resolver, - "getPersistedUriPermissions", - "()Ljava/util/List;", - &[], - ) - .map_err(|e| platform_err("getPersistedUriPermissions", e))? - .l() - .map_err(|e| platform_err("result", e))?; - - let size = env - .call_method(&list, "size", "()I", &[]) - .map_err(|e| platform_err("size", e))? - .i() - .map_err(|e| platform_err("size int", e))?; - - for i in 0..size { - let perm = env - .call_method( - &list, - "get", - "(I)Ljava/lang/Object;", - &[jni::objects::JValueGen::Int(i)], - ) - .map_err(|e| platform_err("get", e))? - .l() - .map_err(|e| platform_err("get object", e))?; - - let perm_uri = env - .call_method(&perm, "getUri", "()Landroid/net/Uri;", &[]) - .map_err(|e| platform_err("getUri", e))? - .l() - .map_err(|e| platform_err("getUri object", e))?; - - let s: String = env - .call_method(&perm_uri, "toString", "()Ljava/lang/String;", &[]) - .map_err(|e| platform_err("toString", e))? - .l() - .map_err(|e| platform_err("toString object", e)) - .and_then(|obj| { - let jstr: jni::objects::JString = obj.into(); - env.get_string(&jstr) - .map_err(|e| platform_err("read string", e)) - .map(|js| js.into()) - })?; - - if s == uri { - return Ok(PermissionStatus::Valid); - } - } - - Ok(PermissionStatus::Revoked) -} - -/// Open a file descriptor for a content URI via `ContentResolver`. -pub(in crate::platform) fn open_fd(uri: &str, mode: &str) -> Result { - let ctx = ndk_context::android_context(); - let vm = - unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(|_| access_err("get JavaVM"))?; - let mut env = vm - .attach_current_thread() - .map_err(|_| access_err("attach thread"))?; - - let uri_obj = parse_uri_for_access(&mut env, uri)?; - let mode_str = env - .new_string(mode) - .map_err(|_| access_err("mode string"))?; - - let activity = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - let resolver = env - .call_method( - &activity, - "getContentResolver", - "()Landroid/content/ContentResolver;", - &[], - ) - .map_err(|_| access_err("getContentResolver"))? - .l() - .map_err(|_| AccessError::InvalidDescriptor)?; - - let pfd = env - .call_method( - &resolver, - "openFileDescriptor", - "(Landroid/net/Uri;Ljava/lang/String;)Landroid/os/ParcelFileDescriptor;", - &[ - jni::objects::JValueGen::Object(&uri_obj), - jni::objects::JValueGen::Object(&mode_str), - ], - ) - .map_err(|_| access_err("openFileDescriptor"))? - .l() - .map_err(|_| AccessError::InvalidDescriptor)?; - - env.call_method(&pfd, "detachFd", "()I", &[]) - .map_err(|_| access_err("detachFd"))? - .i() - .map_err(|_| AccessError::InvalidDescriptor) -} - -/// Parse a URI string for access-error contexts. -fn parse_uri_for_access<'a>( - env: &mut jni::JNIEnv<'a>, - uri: &str, -) -> Result, AccessError> { - let cls = env - .find_class("android/net/Uri") - .map_err(|_| access_err("Uri class"))?; - let s = env.new_string(uri).map_err(|_| access_err("URI string"))?; - env.call_static_method( - &cls, - "parse", - "(Ljava/lang/String;)Landroid/net/Uri;", - &[jni::objects::JValueGen::Object(&s)], - ) - .map_err(|_| access_err("Uri.parse"))? - .l() - .map_err(|_| AccessError::InvalidDescriptor) -} - -fn access_err(ctx: &str) -> AccessError { - AccessError::Platform { - message: format!("{ctx} failed"), - } -} diff --git a/patches/loki-file-access/src/platform/android/jni_ime.rs b/patches/loki-file-access/src/platform/android/jni_ime.rs deleted file mode 100644 index f38a622f..00000000 --- a/patches/loki-file-access/src/platform/android/jni_ime.rs +++ /dev/null @@ -1,149 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! JNI install of the Android soft-keyboard (IME) visibility listener. -//! -//! `ImeInsetsListener` (a Java shim) observes the decor view's window insets and -//! calls back into `nativeOnImeInsetsChanged` on every IME visibility change — -//! including the user-initiated dismiss / re-summon that the OS otherwise never -//! reports to a `NativeActivity`. -//! -//! Install flow: -//! 1. Load the shim through the *application* class loader — a JNI-attached -//! native thread's default `FindClass` loader resolves only framework -//! classes, so an app class must be reached via the activity's own loader. -//! 2. Bind the native callback with `RegisterNatives`, so the binding does not -//! depend on the host application's `.so` name (unlike symbol-name -//! resolution, which is class-loader-scoped on Android 7+). -//! 3. Call the shim's static `install`, which registers the decor-view listener -//! on the UI thread. - -use std::ffi::c_void; -use std::sync::{Mutex, OnceLock}; - -use jni::objects::{JClass, JObject, JValueGen}; -use jni::sys::jboolean; -use jni::{JNIEnv, NativeMethod}; - -/// Fully-qualified name of the Java shim (for `ClassLoader.loadClass`). -const IME_CLASS_DOT: &str = "io.github.appthere.lokifileaccess.ImeInsetsListener"; - -/// Closure invoked (on the Android UI thread) whenever the soft keyboard's -/// visibility changes. `true` = keyboard now visible, `false` = collapsed. -type ImeCallback = Box; -static IME_CALLBACK: OnceLock>> = OnceLock::new(); - -fn ime_callback() -> &'static Mutex> { - IME_CALLBACK.get_or_init(|| Mutex::new(None)) -} - -/// Register the closure invoked on every soft-keyboard visibility change. -/// -/// Call once before [`install_ime_listener`]. A later call replaces the -/// previous closure. -pub fn set_ime_visibility_listener(callback: ImeCallback) { - if let Ok(mut guard) = ime_callback().lock() { - *guard = Some(callback); - } -} - -/// Install the decor-view IME inset listener for `activity_ptr` -/// (`AndroidApp::activity_as_ptr()`). -/// -/// Returns `true` when the Java `install` was invoked. Returns `false` on a null -/// pointer or any JNI failure; the Java side additionally no-ops below API 30, -/// matching the query-side fallback. Installing twice simply replaces the decor -/// view's listener. -pub fn install_ime_listener(activity_ptr: *mut c_void) -> bool { - if activity_ptr.is_null() { - return false; - } - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the JVM pointer initialised by android-activity - // before android_main; valid for the process lifetime. - let vm = match unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) } { - Ok(vm) => vm, - Err(_) => return false, - }; - let mut env = match vm.attach_current_thread() { - Ok(env) => env, - Err(_) => return false, - }; - let ok = install_with_env(&mut env, activity_ptr).is_some(); - // Clear any pending exception (e.g. from a failed class load) so the calling - // thread stays usable. - let _ = env.exception_clear(); - ok -} - -fn install_with_env(env: &mut JNIEnv<'_>, activity_ptr: *mut c_void) -> Option<()> { - // SAFETY: `activity_ptr` is the global-ref activity jobject owned by - // AndroidApp, valid for the activity's lifetime. - let activity = unsafe { JObject::from_raw(activity_ptr.cast()) }; - - let class = load_app_class(env, &activity)?; - register_native(env, &class)?; - - // ImeInsetsListener.install(activity) - env.call_static_method( - &class, - "install", - "(Landroid/app/Activity;)V", - &[JValueGen::Object(&activity)], - ) - .ok()?; - Some(()) -} - -/// Load `ImeInsetsListener` through the application class loader. -/// -/// A native thread's default class loader resolves only framework classes, so -/// we reach the app class via the activity's own `getClassLoader().loadClass`. -fn load_app_class<'a>(env: &mut JNIEnv<'a>, activity: &JObject<'_>) -> Option> { - let activity_class = env.get_object_class(activity).ok()?; - let loader = env - .call_method( - &activity_class, - "getClassLoader", - "()Ljava/lang/ClassLoader;", - &[], - ) - .ok()? - .l() - .ok()?; - let name = env.new_string(IME_CLASS_DOT).ok()?; - let class_obj = env - .call_method( - &loader, - "loadClass", - "(Ljava/lang/String;)Ljava/lang/Class;", - &[JValueGen::Object(&name)], - ) - .ok()? - .l() - .ok()?; - Some(JClass::from(class_obj)) -} - -/// Bind `nativeOnImeInsetsChanged` to [`ime_insets_changed`] via -/// `RegisterNatives`, so the callback resolves regardless of the host -/// application's native-library name. -fn register_native(env: &mut JNIEnv<'_>, class: &JClass<'_>) -> Option<()> { - let methods = [NativeMethod { - name: "nativeOnImeInsetsChanged".into(), - sig: "(Z)V".into(), - fn_ptr: ime_insets_changed as *mut c_void, - }]; - env.register_native_methods(class, &methods).ok() -} - -/// JNI callback invoked by `ImeInsetsListener.onApplyWindowInsets` on the -/// Android UI thread whenever the soft keyboard's visibility changes. -extern "system" fn ime_insets_changed(_env: JNIEnv<'_>, _class: JClass<'_>, ime_visible: jboolean) { - let visible = ime_visible != 0; - if let Ok(guard) = ime_callback().lock() - && let Some(callback) = guard.as_ref() - { - callback(visible); - } -} diff --git a/patches/loki-file-access/src/platform/android/jni_insets.rs b/patches/loki-file-access/src/platform/android/jni_insets.rs deleted file mode 100644 index 84088aed..00000000 --- a/patches/loki-file-access/src/platform/android/jni_insets.rs +++ /dev/null @@ -1,294 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! JNI query for Android system-bar heights. -//! -//! Uses `Resources.getDimensionPixelSize` with the well-known system resource -//! identifiers `status_bar_height` and `navigation_bar_height`. These resource -//! identifiers are available immediately at process start — before the window -//! is laid out — making them safe to query from `android_main`. -//! -//! Returns heights in density-independent pixels (dp / CSS px) so callers can -//! apply them directly as CSS `padding` values. - -use jni::objects::{JObject, JValueGen}; -use jni::JNIEnv; - -/// Query Android system-bar heights from OS resources. -/// -/// Returns `(top_dp, bottom_dp)` where: -/// - `top_dp` is the status-bar height (top of screen) -/// - `bottom_dp` is the navigation-bar height (bottom of screen; 0 when using -/// full gesture navigation with no visible bar) -/// -/// Falls back to `(24.0, 0.0)` on any JNI failure so the status bar area is -/// always reserved even if the exact height cannot be determined. -pub(super) fn query_insets_dp() -> (f32, f32) { - // Clear any stale JNI exception from the fallback path so the caller's - // thread remains usable. Any pending exception is cleared at the end of - // do_query regardless of the outcome. - do_query().unwrap_or((24.0, 0.0)) -} - -// ── Implementation ──────────────────────────────────────────────────────────── - -fn do_query() -> Option<(f32, f32)> { - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the JVM pointer initialised by android-activity - // before android_main is called. It is valid for the process lifetime. - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; - let mut env = vm.attach_current_thread().ok()?; - - let result = query_with_env(&mut env); - - // Clear any pending JNI exception so the calling thread stays usable. - let _ = env.exception_clear(); - - result -} - -fn query_with_env(env: &mut JNIEnv<'_>) -> Option<(f32, f32)> { - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the Application jobject; Application IS a - // Context and provides Resources, so this is safe for resource queries. - let context = unsafe { JObject::from_raw(ctx.context().cast()) }; - - // context.getResources() → android.content.res.Resources - let resources = env - .call_method( - &context, - "getResources", - "()Landroid/content/res/Resources;", - &[], - ) - .ok()? - .l() - .ok()?; - - // resources.getDisplayMetrics() → android.util.DisplayMetrics - let metrics = env - .call_method( - &resources, - "getDisplayMetrics", - "()Landroid/util/DisplayMetrics;", - &[], - ) - .ok()? - .l() - .ok()?; - - // DisplayMetrics.density: float — e.g. 1.0 (mdpi), 2.0 (xhdpi), 3.0 (xxhdpi) - let density = env.get_field(&metrics, "density", "F").ok()?.f().ok()?; - if density <= 0.0 { - return None; - } - - let top_dp = dimen_dp(env, &resources, "status_bar_height", density).unwrap_or(24.0); - let bottom_dp = dimen_dp(env, &resources, "navigation_bar_height", density).unwrap_or(0.0); - - Some((top_dp, bottom_dp)) -} - -// ── Orientation-aware window insets (edge-to-edge) ────────────────────────────── - -/// Query the actual per-side safe-area insets from the activity's window, in dp. -/// -/// Returns `(top, bottom, left, right)` from -/// `decorView.getRootWindowInsets().getInsets(systemBars | displayCutout | ime)` -/// — the real, orientation-aware insets for an edge-to-edge window (e.g. in -/// landscape the navigation bar / cutout move to a side, so `left`/`right` -/// become non-zero and `top` shrinks). Unlike [`query_insets_dp`], this is not -/// orientation-independent. -/// -/// The mask also includes the soft-keyboard (IME) inset, so when the keyboard -/// is visible the returned `bottom` grows to the keyboard height (the -/// `getInsets` union takes the per-side max, and `ime()` only contributes a -/// bottom inset). Re-querying this after the keyboard is shown/hidden — see the -/// IME-settle re-sync in `blitz-shell` — lets the app reserve a bottom safe -/// area for the keyboard on a `NativeActivity` whose surface does not resize. -/// -/// `activity_ptr` is the activity `jobject` from -/// `android_activity::AndroidApp::activity_as_ptr()` — the `ndk_context` context -/// is the *Application*, which has no window, so the activity must be passed in. -/// -/// Returns `None` (caller should fall back to [`query_insets_dp`]) when the view -/// is not yet attached (`getRootWindowInsets` is null), on API < 30 -/// (`getInsets(int)` unavailable), or on any JNI failure. -pub(super) fn query_window_insets_dp( - activity_ptr: *mut std::ffi::c_void, -) -> Option<(f32, f32, f32, f32)> { - if activity_ptr.is_null() { - return None; - } - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the JVM pointer initialised by android-activity - // before android_main; valid for the process lifetime. - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; - let mut env = vm.attach_current_thread().ok()?; - let result = window_insets_with_env(&mut env, activity_ptr); - // Clear any pending exception (e.g. NoSuchMethodError on API < 30) so the - // calling thread stays usable. - let _ = env.exception_clear(); - result -} - -fn window_insets_with_env( - env: &mut JNIEnv<'_>, - activity_ptr: *mut std::ffi::c_void, -) -> Option<(f32, f32, f32, f32)> { - // SAFETY: `activity_ptr` is a global reference jobject owned by AndroidApp, - // valid for the activity's lifetime. - let activity = unsafe { JObject::from_raw(activity_ptr.cast()) }; - - // activity.getWindow().getDecorView().getRootWindowInsets() - let window = env - .call_method(&activity, "getWindow", "()Landroid/view/Window;", &[]) - .ok()? - .l() - .ok()?; - let decor = env - .call_method(&window, "getDecorView", "()Landroid/view/View;", &[]) - .ok()? - .l() - .ok()?; - let insets_obj = env - .call_method( - &decor, - "getRootWindowInsets", - "()Landroid/view/WindowInsets;", - &[], - ) - .ok()? - .l() - .ok()?; - if insets_obj.as_raw().is_null() { - return None; // view not attached yet - } - - // mask = WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout() - // | WindowInsets.Type.ime() (all static int methods, API 30+). - let type_cls = env.find_class("android/view/WindowInsets$Type").ok()?; - let system_bars = env - .call_static_method(&type_cls, "systemBars", "()I", &[]) - .ok()? - .i() - .ok()?; - let cutout = env - .call_static_method(&type_cls, "displayCutout", "()I", &[]) - .ok()? - .i() - .ok()?; - // Fold in the soft-keyboard (IME) inset so `bottom` reserves space for the - // keyboard when it is visible. `getInsets` returns the per-side union and - // `ime()` contributes only a bottom inset, so top/left/right are unaffected - // while the keyboard is hidden (its bottom inset is then 0). `ime()` is API - // 30+, the same level as `getInsets(int)`, so the existing `.ok()?` - // fallback to `query_insets_dp` already covers API < 30. - let ime = env - .call_static_method(&type_cls, "ime", "()I", &[]) - .ok()? - .i() - .ok()?; - let mask = system_bars | cutout | ime; - - // insets = windowInsets.getInsets(mask) → android.graphics.Insets (API 30+) - let insets = env - .call_method( - &insets_obj, - "getInsets", - "(I)Landroid/graphics/Insets;", - &[JValueGen::Int(mask)], - ) - .ok()? - .l() - .ok()?; - let left = env.get_field(&insets, "left", "I").ok()?.i().ok()?; - let top = env.get_field(&insets, "top", "I").ok()?.i().ok()?; - let right = env.get_field(&insets, "right", "I").ok()?.i().ok()?; - let bottom = env.get_field(&insets, "bottom", "I").ok()?.i().ok()?; - - let density = display_density(env)?; - if density <= 0.0 { - return None; - } - Some(( - top as f32 / density, - bottom as f32 / density, - left as f32 / density, - right as f32 / density, - )) -} - -/// Display density (e.g. 2.625) from the Application's `DisplayMetrics`. -fn display_density(env: &mut JNIEnv<'_>) -> Option { - let ctx = ndk_context::android_context(); - // SAFETY: ndk_context stores the Application jobject; it provides Resources. - let context = unsafe { JObject::from_raw(ctx.context().cast()) }; - let resources = env - .call_method( - &context, - "getResources", - "()Landroid/content/res/Resources;", - &[], - ) - .ok()? - .l() - .ok()?; - let metrics = env - .call_method( - &resources, - "getDisplayMetrics", - "()Landroid/util/DisplayMetrics;", - &[], - ) - .ok()? - .l() - .ok()?; - env.get_field(&metrics, "density", "F").ok()?.f().ok() -} - -/// Look up one Android system dimension resource and convert physical pixels → dp. -fn dimen_dp( - env: &mut JNIEnv<'_>, - resources: &JObject<'_>, - name: &str, - density: f32, -) -> Option { - let name_jstr = env.new_string(name).ok()?; - let type_jstr = env.new_string("dimen").ok()?; - let pkg_jstr = env.new_string("android").ok()?; - - // resources.getIdentifier(name, "dimen", "android") → int resource ID - let res_id: i32 = env - .call_method( - resources, - "getIdentifier", - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)I", - &[ - JValueGen::Object(&name_jstr), - JValueGen::Object(&type_jstr), - JValueGen::Object(&pkg_jstr), - ], - ) - .ok()? - .i() - .ok()?; - - if res_id == 0 { - return Some(0.0); - } - - // resources.getDimensionPixelSize(resId) → int physical pixels - let px: i32 = env - .call_method( - resources, - "getDimensionPixelSize", - "(I)I", - &[JValueGen::Int(res_id)], - ) - .ok()? - .i() - .ok()?; - - Some(px as f32 / density) -} diff --git a/patches/loki-file-access/src/platform/android/jni_intents.rs b/patches/loki-file-access/src/platform/android/jni_intents.rs deleted file mode 100644 index 48af0478..00000000 --- a/patches/loki-file-access/src/platform/android/jni_intents.rs +++ /dev/null @@ -1,238 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! JNI helpers for post-pick SAF operations. -//! -//! Covers `ContentResolver.takePersistableUriPermission`, URI parsing, and the -//! content resolver accessor used by [`super::jni_fd`]. Intent launching is -//! handled by [`super::jni_activity`] (via `FilePickerActivity` trampoline). - -use super::jni_common::{attach_err, jvm_err, platform_err}; -use crate::error::PickerError; - -// ── SAF post-pick operations ────────────────────────────────────────────────── - -/// Call `ContentResolver.takePersistableUriPermission` for a content URI. -/// -/// Grants READ | WRITE persistable permission so the URI survives app restarts. -pub(super) fn take_persistable_uri_permission(uri: &str) -> Result<(), PickerError> { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.map_err(jvm_err)?; - let mut env = vm.attach_current_thread().map_err(attach_err)?; - - let uri_obj = parse_uri(&mut env, uri)?; - let resolver = get_content_resolver(&mut env, &ctx)?; - - // FLAG_GRANT_READ_URI_PERMISSION (1) | FLAG_GRANT_WRITE_URI_PERMISSION (2) - env.call_method( - &resolver, - "takePersistableUriPermission", - "(Landroid/net/Uri;I)V", - &[ - jni::objects::JValueGen::Object(&uri_obj), - jni::objects::JValueGen::Int(3), - ], - ) - .map_err(|e| platform_err("takePersistableUriPermission", e))?; - - Ok(()) -} - -// ── Display name query ──────────────────────────────────────────────────────── - -/// Query the human-readable display name for a content URI from ContentResolver. -/// -/// Uses `OpenableColumns.DISPLAY_NAME` (`"_display_name"`) via a cursor query. -/// Falls back to the last URI path segment (percent-decoded) on any JNI failure. -pub(super) fn query_display_name(uri_str: &str) -> String { - query_display_name_inner(uri_str).unwrap_or_else(|| { - let raw = uri_str.rsplit('/').next().unwrap_or("unnamed"); - percent_decode_last_segment(raw) - }) -} - -fn query_display_name_inner(uri_str: &str) -> Option { - let ctx = ndk_context::android_context(); - let vm = unsafe { jni::JavaVM::from_raw(ctx.vm().cast()) }.ok()?; - let mut env = vm.attach_current_thread().ok()?; - - let uri_obj = parse_uri(&mut env, uri_str).ok()?; - let resolver = get_content_resolver(&mut env, &ctx).ok()?; - - // projection = new String[]{"_display_name"} - let str_cls = env.find_class("java/lang/String").ok()?; - let col_str = env.new_string("_display_name").ok()?; - let projection = env.new_object_array(1, &str_cls, &col_str).ok()?; - - // ContentResolver.query(uri, projection, null, null, null) → Cursor - // A misbehaving SAF provider may throw instead of returning null. Clear any - // pending JNI exception before returning so subsequent calls are not poisoned. - let null_obj = jni::objects::JObject::null(); - let query_result = env.call_method( - &resolver, - "query", - "(Landroid/net/Uri;[Ljava/lang/String;Ljava/lang/String;\ - [Ljava/lang/String;Ljava/lang/String;)Landroid/database/Cursor;", - &[ - jni::objects::JValueGen::Object(&uri_obj), - jni::objects::JValueGen::Object(&projection), - jni::objects::JValueGen::Object(&null_obj), - jni::objects::JValueGen::Object(&null_obj), - jni::objects::JValueGen::Object(&null_obj), - ], - ); - let cursor = match query_result { - Ok(v) => v.l().ok()?, - Err(_) => { - let _ = env.exception_clear(); - return None; - } - }; - - if cursor.is_null() { - return None; - } - - // Extract the display name, then close the cursor unconditionally. - // read_display_name uses ok()? internally, which can exit without clearing - // a pending JNI exception from moveToFirst/getColumnIndex/getString. - // Calling close() while an exception is pending violates the JNI spec and - // risks an ART abort, so we clear any pending exception first. - let result = read_display_name(&mut env, &cursor); - let _ = env.exception_clear(); - let _ = env.call_method(&cursor, "close", "()V", &[]); - result -} - -/// Read `_display_name` from an already-moved-to-first cursor. -/// Separated from the main function so that every return path is guaranteed -/// to reach `cursor.close()` in the caller — no `?` can skip it. -fn read_display_name( - env: &mut jni::JNIEnv<'_>, - cursor: &jni::objects::JObject<'_>, -) -> Option { - let moved = env - .call_method(cursor, "moveToFirst", "()Z", &[]) - .ok()? - .z() - .ok()?; - - if !moved { - return None; - } - - // Column index is always 0 when the single-column projection {"_display_name"} - // is honoured by the provider. Use getColumnIndex as a defensive check. - let col_name = env.new_string("_display_name").ok()?; - let col_idx = env - .call_method( - cursor, - "getColumnIndex", - "(Ljava/lang/String;)I", - &[jni::objects::JValueGen::Object(&col_name)], - ) - .ok()? - .i() - .ok()?; - - if col_idx < 0 { - return None; - } - - let s_obj = env - .call_method( - cursor, - "getString", - "(I)Ljava/lang/String;", - &[jni::objects::JValueGen::Int(col_idx)], - ) - .ok()? - .l() - .ok()?; - - if s_obj.is_null() { - return None; - } - - let jstr: jni::objects::JString = s_obj.into(); - env.get_string(&jstr).ok().map(|js| String::from(js)) -} - -/// Minimal percent-decoder for the last URI path segment used as fallback. -/// -/// Decodes `%XX` sequences by accumulating raw bytes and then interpreting the -/// entire buffer as UTF-8. This correctly handles multi-byte UTF-8 sequences -/// such as `%C3%A9` (é) — decoding each byte individually via `char::from(u8)` -/// would produce mojibake for any non-ASCII character. -/// -/// Invalid or incomplete `%XX` sequences (e.g. `%GG`, `%2`, `%`) are emitted -/// literally rather than dropped. In URI path segments `+` is a literal plus -/// sign, not a space — only `%20` encodes a space. -fn percent_decode_last_segment(s: &str) -> String { - let mut buf: Vec = Vec::with_capacity(s.len()); - let bytes = s.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'%' && i + 2 < bytes.len() { - let hi = (bytes[i + 1] as char).to_digit(16); - let lo = (bytes[i + 2] as char).to_digit(16); - if let (Some(h), Some(l)) = (hi, lo) { - buf.push((h * 16 + l) as u8); - i += 3; - continue; - } - } - // Not a valid %XX sequence — emit the byte as-is. - buf.push(bytes[i]); - i += 1; - } - // from_utf8 avoids a Cow allocation on valid UTF-8 (the common case for - // URI path segments). The lossy fallback is only reached for malformed - // byte sequences, which should not occur in well-formed content URIs. - let decoded = String::from_utf8(buf) - .unwrap_or_else(|e| String::from_utf8_lossy(&e.into_bytes()).into_owned()); - if decoded.is_empty() { "unnamed".to_string() } else { decoded } -} - -// ── Shared helpers ──────────────────────────────────────────────────────────── - -/// Parse a URI string into a `Uri` JNI object via `Uri.parse(String)`. -pub(super) fn parse_uri<'a>( - env: &mut jni::JNIEnv<'a>, - uri: &str, -) -> Result, PickerError> { - let cls = env - .find_class("android/net/Uri") - .map_err(|e| platform_err("Uri class", e))?; - let s = env - .new_string(uri) - .map_err(|e| platform_err("URI string", e))?; - env.call_static_method( - &cls, - "parse", - "(Ljava/lang/String;)Landroid/net/Uri;", - &[jni::objects::JValueGen::Object(&s)], - ) - .map_err(|e| platform_err("Uri.parse", e))? - .l() - .map_err(|e| platform_err("Uri.parse object", e)) -} - -/// Get the `ContentResolver` from the ndk_context Application/Activity. -pub(super) fn get_content_resolver<'a>( - env: &mut jni::JNIEnv<'a>, - ctx: &ndk_context::AndroidContext, -) -> Result, PickerError> { - // SAFETY: ndk_context stores a valid Application jobject. - let context = unsafe { jni::objects::JObject::from_raw(ctx.context().cast()) }; - env.call_method( - &context, - "getContentResolver", - "()Landroid/content/ContentResolver;", - &[], - ) - .map_err(|e| platform_err("getContentResolver", e))? - .l() - .map_err(|e| platform_err("getContentResolver object", e)) -} - diff --git a/patches/loki-file-access/src/platform/android/mod.rs b/patches/loki-file-access/src/platform/android/mod.rs deleted file mode 100644 index 83d129c2..00000000 --- a/patches/loki-file-access/src/platform/android/mod.rs +++ /dev/null @@ -1,368 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Android file-picker implementation using the Storage Access Framework (SAF). -//! -//! File access is mediated through content URIs and `ContentResolver`, -//! ensuring that the app never accesses files via filesystem paths (unreliable -//! on modern Android). -//! -//! # Persistence -//! -//! After the user selects a file, `ContentResolver.takePersistableUriPermission()` -//! is called with READ | WRITE flags. This ensures the URI grant survives app -//! restarts and device reboots. -//! -//! # NativeActivity integration -//! -//! Call [`init_android`] from `android_main` before launching Dioxus: -//! -//! ```rust,no_run -//! fn android_main(android_app: android_activity::AndroidApp) { -//! // init_android is a no-op kept for API compatibility; ndk_context -//! // (initialised by android-activity before android_main) provides the -//! // Application context used by all JNI calls. -//! unsafe { loki_file_access::init_android(std::ptr::null_mut()); } -//! blitz_shell::set_android_app(android_app); -//! dioxus::launch(App); -//! } -//! ``` -//! -//! # Result delivery -//! -//! `ANativeActivityCallbacks` has no `onActivityResult` slot, so results cannot -//! be delivered directly to NativeActivity. Instead, NativeActivity calls -//! `startActivity(Intent → FilePickerActivity)`, which is a transparent Java -//! trampoline that runs its own `startActivityForResult(ACTION_OPEN_DOCUMENT)`. -//! `FilePickerActivity.onActivityResult` delivers the URI via the pre-compiled -//! JNI hook `Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult`. -//! -//! **Prerequisite**: `FilePickerActivity` must be declared in `AndroidManifest.xml` -//! and its compiled `classes.dex` injected into the APK (see `scripts/build-android.ps1`). - -mod jni_activity; -mod jni_common; -mod jni_fd; -mod jni_ime; -mod jni_insets; -mod jni_intents; - -use std::sync::{Arc, Mutex, OnceLock}; - -use crate::api::{PickOptions, SaveOptions}; -use crate::error::{AccessError, PickerError}; -use crate::future::{deliver, new_pick_future}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// Pending pick state shared between the intent launcher and the JNI callback. -/// -/// The payload is `Vec`: empty means cancelled, non-empty contains the -/// selected content URIs (one for single-pick, one or more for multi-pick). -static PENDING_PICK: OnceLock< - Mutex>>>>>, -> = OnceLock::new(); - -fn pending_pick( -) -> &'static Mutex>>>>> { - PENDING_PICK.get_or_init(|| Mutex::new(None)) -} - -// ── Public Android initialisation ───────────────────────────────────────────── - -/// Initialise the file-access layer. -/// -/// Must be called from `android_main` before launching Dioxus. The parameter -/// is accepted for API compatibility but is no longer stored — `startActivity` -/// now uses the Application context from `ndk_context` directly, which is set -/// up by `android-activity` before `android_main` is called. -/// -/// # Safety -/// -/// The caller is responsible for ensuring `android_main` setup (including -/// `ndk_context` initialisation by `android-activity`) is complete before -/// any file-picker calls are made. -pub unsafe fn init_android(_activity_as_ptr: *mut std::ffi::c_void) { - // No-op: ndk_context provides the Application object used by all JNI calls. -} - -/// Query Android system-bar heights from OS resources. -/// -/// Returns `(top_dp, bottom_dp)` — heights in density-independent pixels for -/// the status bar (top) and navigation bar (bottom). Safe to call immediately -/// after [`init_android`] before the window is laid out. -pub fn query_insets_dp() -> (f32, f32) { - jni_insets::query_insets_dp() -} - -/// Query orientation-aware safe-area insets from the activity window, in dp. -/// -/// Returns `(top, bottom, left, right)` from the real window insets (system bars -/// + display cutout), which — unlike [`query_insets_dp`] — change with -/// orientation. `activity_ptr` is `AndroidApp::activity_as_ptr()`. Returns -/// `None` before the window is laid out / on API < 30; callers fall back to -/// [`query_insets_dp`]. -pub fn query_window_insets_dp(activity_ptr: *mut std::ffi::c_void) -> Option<(f32, f32, f32, f32)> { - jni_insets::query_window_insets_dp(activity_ptr) -} - -// ── Soft-keyboard (IME) visibility signal ───────────────────────────────────── - -pub use jni_ime::{install_ime_listener, set_ime_visibility_listener}; - -// ── JNI result callback (called from Java FilePickerActivity) ───────────────── - -/// Delivers the selected URI (or `null` for cancellation) to the pending Rust future. -/// -/// Called by `FilePickerActivity.onActivityResult` via JNI. The method is -/// declared `private native void nativeOnResult(String)` in the Java class -/// `io.github.appthere.lokifileaccess.FilePickerActivity`, so the JVM resolves -/// it to this exported symbol automatically once the native library is loaded. -/// -/// **The native library is loaded by NativeActivity before `FilePickerActivity` -/// is ever created**, so `System.loadLibrary` is not needed in the Java class. -/// -/// # Safety -/// -/// Must be called from a JNI-attached Java thread. -#[allow(non_snake_case)] -#[unsafe(no_mangle)] -pub unsafe extern "system" fn Java_io_github_appthere_lokifileaccess_FilePickerActivity_nativeOnResult( - mut env: jni::JNIEnv<'_>, - _this: jni::objects::JObject<'_>, - uri: jni::objects::JString<'_>, -) { - // The Java side sends a '\n'-delimited string of content URIs. - // Null or an empty string means the user cancelled. - let uris: Vec = if uri.is_null() { - Vec::new() - } else { - env.get_string(&uri) - .ok() - .map(|s| { - let joined: String = s.into(); - joined - .split('\n') - .filter(|s| !s.is_empty()) - .map(str::to_owned) - .collect() - }) - .unwrap_or_default() - }; - on_activity_result(uris); -} - -// ── Pick / save entry points ────────────────────────────────────────────────── - -/// Pick a single file for reading via `ACTION_OPEN_DOCUMENT`. -pub(crate) async fn pick_open_single( - options: PickOptions, -) -> Result, PickerError> { - let uris = launch_open_intent(&options, false).await?; - match uris.into_iter().next() { - None => Ok(None), - Some(uri_str) => { - jni_intents::take_persistable_uri_permission(&uri_str)?; - let display_name = jni_intents::query_display_name(&uri_str); - Ok(Some(FileAccessToken { - inner: TokenInner::Android { - uri: uri_str, - display_name, - mime_type: None, - }, - })) - } - } -} - -/// Pick multiple files for reading via `ACTION_OPEN_DOCUMENT`. -pub(crate) async fn pick_open_multi( - options: PickOptions, -) -> Result, PickerError> { - let uris = launch_open_intent(&options, true).await?; - let mut tokens = Vec::with_capacity(uris.len()); - for uri_str in uris { - // Skip URIs whose persistable grant fails (e.g. a cloud provider that - // revoked the grant between SAF delivery and this call, or a URI that - // exceeds Android's per-app persisted-permission quota). Aborting the - // entire batch with `?` would orphan grants already taken for earlier - // URIs — those cannot be un-granted, silently consuming quota. - if jni_intents::take_persistable_uri_permission(&uri_str).is_err() { - tracing::warn!("loki-file-access: skipping URI with failed permission grant"); - continue; - } - let display_name = jni_intents::query_display_name(&uri_str); - tokens.push(FileAccessToken { - inner: TokenInner::Android { - uri: uri_str, - display_name, - mime_type: None, - }, - }); - } - Ok(tokens) -} - -/// Pick a save location via `ACTION_CREATE_DOCUMENT`. -pub(crate) async fn pick_save( - options: SaveOptions, -) -> Result, PickerError> { - let uris = launch_create_intent(&options).await?; - match uris.into_iter().next() { - None => Ok(None), - Some(uri_str) => { - jni_intents::take_persistable_uri_permission(&uri_str)?; - let display_name = options - .suggested_name - .clone() - .unwrap_or_else(|| "untitled".into()); - Ok(Some(FileAccessToken { - inner: TokenInner::Android { - uri: uri_str, - display_name, - mime_type: options.mime_type.clone(), - }, - })) - } - } -} - -/// Open a content URI for reading. -pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Android { uri, .. } => { - let fd = jni_fd::open_fd(uri, "r")?; - // SAFETY: `open_fd` returns a valid file descriptor from - // Android's `ContentResolver.openFileDescriptor` after detaching it. - // The caller takes ownership; it must not be double-closed. - let file: std::fs::File = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) }; - Ok(Box::new(file)) - } - _ => Err(AccessError::Platform { - message: "non-Android token on Android platform".into(), - }), - } -} - -/// Open a content URI for writing. -pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Android { uri, .. } => { - let fd = jni_fd::open_fd(uri, "w")?; - // SAFETY: Same invariant as `open_read` — see above. - let file: std::fs::File = unsafe { std::os::fd::FromRawFd::from_raw_fd(fd) }; - Ok(Box::new(file)) - } - _ => Err(AccessError::Platform { - message: "non-Android token on Android platform".into(), - }), - } -} - -/// Delete the file referenced by a token. -/// -/// Deleting a SAF document URI requires `DocumentsContract.deleteDocument`, -/// which is not yet wired through JNI. Return an explicit unsupported error -/// rather than silently succeeding. -pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { - Err(AccessError::Unsupported { - operation: "delete (Android SAF content-URI deletion not implemented)".into(), - }) -} - -/// Check whether a persistable URI permission is still held. -pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { - match inner { - TokenInner::Android { uri, .. } => jni_fd::check_persisted_permission(uri) - .unwrap_or(PermissionStatus::Unknown), - _ => PermissionStatus::Unknown, - } -} - -/// Deliver the selected URIs (or an empty Vec for cancellation) to the pending future. -pub fn on_activity_result(uris: Vec) { - let guard = match pending_pick().lock() { - Ok(g) => g, - Err(poisoned) => poisoned.into_inner(), - }; - if let Some(ref state) = *guard { - deliver(state, uris); - } -} - -// ── Internal helpers ────────────────────────────────────────────────────────── - -/// Seconds to wait before treating a non-returning file picker as abandoned. -const PICKER_TIMEOUT_SECS: u64 = 600; - -/// Spawn a background thread that cancels the pick after [`PICKER_TIMEOUT_SECS`]. -/// -/// If `nativeOnResult` fires before the deadline, the PickState already has a -/// result (`result.is_some()`). The thread then exits without overwriting it, -/// so a real URI is never silently replaced by a spurious cancellation. -/// -/// This guards against Android 12+ silently blocking `startActivity` when the -/// app has no foreground window: without this thread, `future.await` hangs -/// forever. The spawned thread exits immediately after the normal pick -/// completes, so the OS thread count stays bounded in typical usage. -fn spawn_timeout_guard(state: Arc>>>) { - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(PICKER_TIMEOUT_SECS)); - // Only deliver if the pick has not already resolved. Overwriting a - // real result here would silently cancel a successful file open. - let waker = { - let mut guard = match state.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - if guard.result.is_some() { - return; // Real result already delivered; nothing to do. - } - guard.result = Some(Vec::new()); - guard.waker.take() - }; - if let Some(w) = waker { - w.wake(); - } - }); -} - -fn store_pending( - state: Arc>>>, -) -> Result<(), PickerError> { - let mut guard = pending_pick().lock().map_err(|e| PickerError::Internal { - message: e.to_string(), - })?; - *guard = Some(state); - Ok(()) -} - -/// Launch `FilePickerActivity` to open a file and await the result. -/// -/// Returns an empty `Vec` on cancellation, or one URI per selected file. -async fn launch_open_intent( - options: &PickOptions, - allow_multiple: bool, -) -> Result, PickerError> { - let (future, state) = new_pick_future::>(); - // Clone the Arc before moving `state` into store_pending so the timeout - // thread can deliver an empty result if nativeOnResult never fires. - let timeout_state = Arc::clone(&state); - store_pending(state)?; - jni_activity::fire_open_file_picker(options, allow_multiple)?; - spawn_timeout_guard(timeout_state); - Ok(future.await) -} - -/// Launch `FilePickerActivity` to save a file and await the result. -/// -/// Returns an empty `Vec` on cancellation, or a single-element `Vec` on success. -async fn launch_create_intent( - options: &SaveOptions, -) -> Result, PickerError> { - let (future, state) = new_pick_future::>(); - let timeout_state = Arc::clone(&state); - store_pending(state)?; - jni_activity::fire_create_file_picker(options)?; - spawn_timeout_guard(timeout_state); - Ok(future.await) -} diff --git a/patches/loki-file-access/src/platform/desktop/filters.rs b/patches/loki-file-access/src/platform/desktop/filters.rs deleted file mode 100644 index 14131183..00000000 --- a/patches/loki-file-access/src/platform/desktop/filters.rs +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! MIME-type-to-extension mapping and extension validation for `rfd` filters. -//! -//! Kept in its own file to stay within the 300-line ceiling for each source -//! file while allowing `super` to use both helpers without qualification. - -/// Returns `true` if `ext` is safe to pass as an `rfd` filter extension. -/// -/// Windows `IFileOpenDialog::SetFileTypes` requires extension strings that -/// contain no dots, slashes, or whitespace. MIME subtype fallbacks for -/// `vnd.*` types (e.g. `vnd.openxmlformats-officedocument.wordprocessingml.document`) -/// contain dots and would produce a filter pattern that matches no real files, -/// causing the dialog to display only folder entries. -pub(super) fn is_valid_extension(ext: &str) -> bool { - !ext.is_empty() - && ext - .bytes() - .all(|b| b != b'.' && b != b'/' && b != b'\\' && !b.is_ascii_whitespace()) -} - -/// Convert MIME types to file extensions for the `rfd` filter. -/// -/// Returns one extension string per input MIME type. Results that are not -/// valid Windows `SetFileTypes` extension strings (containing dots, slashes, -/// or whitespace) should be discarded by the caller via [`is_valid_extension`] -/// before passing to `rfd::AsyncFileDialog::add_filter`. -/// -/// For MIME types not in the static table the subtype component is returned -/// as-is; [`is_valid_extension`] will reject any subtype that contains dots -/// (e.g. all `vnd.*` types not listed here). -pub(super) fn mime_types_to_extensions(mime_types: &[String]) -> Vec { - mime_types - .iter() - .map(|mime| match mime.as_str() { - // Plain text / markup - "text/plain" => "txt".into(), - "text/html" => "html".into(), - "text/css" => "css".into(), - "text/csv" => "csv".into(), - "text/markdown" | "text/x-markdown" => "md".into(), - "text/rtf" | "application/rtf" => "rtf".into(), - // Data formats - "application/json" => "json".into(), - "application/xml" | "text/xml" => "xml".into(), - "application/pdf" => "pdf".into(), - // Archives - "application/zip" => "zip".into(), - "application/x-tar" => "tar".into(), - "application/gzip" | "application/x-gzip" => "gz".into(), - "application/x-bzip2" => "bz2".into(), - "application/x-7z-compressed" => "7z".into(), - // Images - "image/png" => "png".into(), - "image/jpeg" => "jpg".into(), - "image/gif" => "gif".into(), - "image/svg+xml" => "svg".into(), - "image/webp" => "webp".into(), - "image/tiff" => "tiff".into(), - "image/bmp" => "bmp".into(), - // Audio / video - "audio/mpeg" => "mp3".into(), - "audio/wav" | "audio/x-wav" => "wav".into(), - "audio/ogg" => "ogg".into(), - "audio/flac" => "flac".into(), - "video/mp4" => "mp4".into(), - "video/webm" => "webm".into(), - "video/x-matroska" => "mkv".into(), - // Microsoft Office (legacy binary formats) - "application/msword" => "doc".into(), - "application/vnd.ms-excel" => "xls".into(), - "application/vnd.ms-powerpoint" => "ppt".into(), - // Microsoft Office Open XML - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => { - "docx".into() - } - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => "xlsx".into(), - "application/vnd.openxmlformats-officedocument.presentationml.presentation" => { - "pptx".into() - } - // OpenDocument formats - "application/vnd.oasis.opendocument.text" => "odt".into(), - "application/vnd.oasis.opendocument.spreadsheet" => "ods".into(), - "application/vnd.oasis.opendocument.presentation" => "odp".into(), - // E-book / other documents - "application/epub+zip" => "epub".into(), - other => { - // Fall back to the subtype component; callers must discard - // results that fail `is_valid_extension` (e.g. vnd.* subtypes - // not listed above that contain dots). - other.split('/').nth(1).unwrap_or(other).to_owned() - } - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn known_mime_types_produce_valid_extensions() { - let cases = [ - ("text/plain", "txt"), - ("application/pdf", "pdf"), - ("application/msword", "doc"), - ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "docx", - ), - ("application/vnd.ms-excel", "xls"), - ( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "xlsx", - ), - ("application/vnd.oasis.opendocument.text", "odt"), - ("image/svg+xml", "svg"), - ("application/epub+zip", "epub"), - ]; - for (mime, expected_ext) in cases { - let result = mime_types_to_extensions(&[mime.to_owned()]); - assert_eq!(result[0], expected_ext, "wrong ext for {mime}"); - assert!( - is_valid_extension(&result[0]), - "ext '{}' for '{mime}' failed is_valid_extension", - result[0] - ); - } - } - - #[test] - fn unlisted_vnd_mime_type_produces_invalid_extension() { - // An unlisted vnd.* type hits the fallback and produces a dotted - // subtype that must be rejected by is_valid_extension. - let result = mime_types_to_extensions(&["application/vnd.example.format".to_owned()]); - assert!( - !is_valid_extension(&result[0]), - "dotted fallback must be invalid" - ); - } - - #[test] - fn is_valid_extension_rejects_dots_and_slashes() { - assert!(!is_valid_extension("")); - assert!(!is_valid_extension("vnd.foo.bar")); - assert!(!is_valid_extension("foo/bar")); - assert!(!is_valid_extension("foo bar")); - assert!(is_valid_extension("docx")); - assert!(is_valid_extension("txt")); - assert!(is_valid_extension("7z")); - } -} diff --git a/patches/loki-file-access/src/platform/desktop/mod.rs b/patches/loki-file-access/src/platform/desktop/mod.rs deleted file mode 100644 index cba1dda8..00000000 --- a/patches/loki-file-access/src/platform/desktop/mod.rs +++ /dev/null @@ -1,420 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Desktop file-picker implementation (Windows, macOS, Linux, BSD). -//! -//! This module wraps [`rfd::AsyncFileDialog`] and simply `.await`s its -//! futures. `rfd` carries its own internal dispatch mechanism and does **not** -//! require Tokio or async-std; any executor that correctly yields between polls -//! works (Dioxus, egui, Iced, `pollster::block_on`, etc.). -//! -//! ## Why `.await` instead of `pollster::block_on` -//! -//! On **macOS**, `rfd::AsyncFileDialog` presents `NSOpenPanel` by dispatching -//! to the main thread via Grand Central Dispatch (`dispatch_async` on the main -//! queue). If the caller blocks its own thread with `pollster::block_on` -//! *and that thread is the main thread* (the typical case in Dioxus Native), -//! GCD can never execute the dispatch — the dialog never appears and the app -//! hangs with a spinning beach ball. -//! -//! Co-operatively awaiting the future with `.await` yields to the executor, -//! keeping the main-thread run loop free to process GCD events. On -//! **Windows** and **Linux** there is no main-thread constraint, so `.await` -//! is equally correct there. -//! -//! ## Linux fallback -//! -//! On Linux, rfd uses the XDG Desktop Portal via D-Bus. When the portal is -//! unavailable (e.g. ChromeOS Crostini), rfd returns `None`. In that case -//! the picker falls back to `zenity` (a GNOME subprocess dialog) if it is -//! installed. The zenity call runs on a dedicated thread so it does not block -//! the async executor. - -mod filters; -#[cfg(target_os = "linux")] -mod zenity; - -use filters::{is_valid_extension, mime_types_to_extensions}; - -use crate::api::{PickOptions, SaveOptions}; -use crate::error::{AccessError, PickerError}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// On Linux, check the `LOKI_FILE_ACCESS_BACKEND` environment variable. -/// -/// Setting it to `"none"` disables the picker and surfaces a clear error -/// instead of a silent no-op. This is a developer escape hatch for -/// environments where neither the XDG Desktop Portal nor zenity is available. -/// Valid values: `"auto"` (default), `"none"`. -#[cfg(target_os = "linux")] -fn check_backend_env() -> Result<(), PickerError> { - if std::env::var("LOKI_FILE_ACCESS_BACKEND").as_deref() == Ok("none") { - return Err(PickerError::Platform { - message: "file picker disabled via LOKI_FILE_ACCESS_BACKEND=none. \ - On Linux, the XDG Desktop Portal or zenity must be available. \ - Unset LOKI_FILE_ACCESS_BACKEND or set it to \"auto\" to re-enable." - .into(), - }); - } - Ok(()) -} - -#[cfg(not(target_os = "linux"))] -#[inline(always)] -fn check_backend_env() -> Result<(), PickerError> { - Ok(()) -} - -/// Pick a single file for reading. -/// -/// Converts `options.mime_types` to file-extension filters understood by the -/// native dialog. On Windows, extensions that contain dots or other -/// characters invalid for `IFileOpenDialog::SetFileTypes` are silently -/// dropped; if all extensions are invalid the filter is omitted entirely so -/// that all files remain visible. -/// -/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. -/// -/// # Errors -/// -/// Returns [`PickerError`] if the platform dialog could not be presented. -/// -/// # Examples -/// -/// ```no_run -/// # async fn example() -> Result<(), loki_file_access::PickerError> { -/// use loki_file_access::{FilePicker, PickOptions}; -/// let token = FilePicker::new() -/// .pick_file_to_open(PickOptions { -/// mime_types: vec!["application/pdf".into()], -/// ..Default::default() -/// }) -/// .await?; -/// # Ok(()) } -/// ``` -pub(crate) async fn pick_open_single( - options: PickOptions, -) -> Result, PickerError> { - check_backend_env()?; - - // Capture filter data before building the rfd dialog so the zenity fallback - // thread closure can take ownership of the same values. - let filter_label = options - .filter_label - .as_deref() - .unwrap_or("Files") - .to_owned(); - let filter_exts: Vec = if !options.mime_types.is_empty() { - mime_types_to_extensions(&options.mime_types) - .into_iter() - .filter(|e| is_valid_extension(e)) - .collect() - } else { - vec![] - }; - - let mut dialog = rfd::AsyncFileDialog::new(); - if !filter_exts.is_empty() { - let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); - dialog = dialog.add_filter(&filter_label, &ext_refs); - } - - match dialog.pick_file().await { - Some(h) => { - let path = h.path().to_path_buf(); - let display_name = file_name_from_path(&path); - Ok(Some(FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - })) - } - None => { - #[cfg(target_os = "linux")] - { - if zenity::is_zenity_available() { - tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); - let (fut, state) = crate::future::new_pick_future(); - std::thread::spawn(move || { - let result = - zenity::pick_file("Open File".into(), filter_label, filter_exts).map( - |opt| { - opt.map(|path| { - let display_name = file_name_from_path(&path); - FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - } - }) - }, - ); - crate::future::deliver(&state, result); - }); - return fut.await; - } - tracing::warn!( - "File picker returned no result. \ - On ChromeOS Crostini or minimal Linux environments, \ - install zenity for file dialog support: sudo apt install zenity" - ); - } - Ok(None) - } - } -} - -/// Pick multiple files for reading. -/// -/// Applies the same extension-validation logic as [`pick_open_single`]: -/// invalid extensions (containing dots etc.) are dropped, and if none remain -/// the filter is omitted so all files stay visible. -/// -/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. -/// -/// # Errors -/// -/// Returns [`PickerError`] if the platform dialog could not be presented. -/// -/// # Examples -/// -/// ```no_run -/// # async fn example() -> Result<(), loki_file_access::PickerError> { -/// use loki_file_access::{FilePicker, PickOptions}; -/// let tokens = FilePicker::new() -/// .pick_files_to_open(PickOptions { -/// mime_types: vec!["image/png".into(), "image/jpeg".into()], -/// ..Default::default() -/// }) -/// .await?; -/// # Ok(()) } -/// ``` -pub(crate) async fn pick_open_multi( - options: PickOptions, -) -> Result, PickerError> { - check_backend_env()?; - - let filter_label = options - .filter_label - .as_deref() - .unwrap_or("Files") - .to_owned(); - let filter_exts: Vec = if !options.mime_types.is_empty() { - mime_types_to_extensions(&options.mime_types) - .into_iter() - .filter(|e| is_valid_extension(e)) - .collect() - } else { - vec![] - }; - - let mut dialog = rfd::AsyncFileDialog::new(); - if !filter_exts.is_empty() { - let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); - dialog = dialog.add_filter(&filter_label, &ext_refs); - } - - match dialog.pick_files().await { - Some(list) => { - let tokens = list - .into_iter() - .map(|h| { - let path = h.path().to_path_buf(); - let display_name = file_name_from_path(&path); - FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - } - }) - .collect(); - Ok(tokens) - } - None => { - #[cfg(target_os = "linux")] - { - if zenity::is_zenity_available() { - tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); - let (fut, state) = crate::future::new_pick_future(); - std::thread::spawn(move || { - let result = - zenity::pick_files("Open Files".into(), filter_label, filter_exts).map( - |paths| { - paths - .into_iter() - .map(|path| { - let display_name = file_name_from_path(&path); - FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - } - }) - .collect() - }, - ); - crate::future::deliver(&state, result); - }); - return fut.await; - } - tracing::warn!( - "File picker returned no result. \ - On ChromeOS Crostini or minimal Linux environments, \ - install zenity for file dialog support: sudo apt install zenity" - ); - } - Ok(vec![]) - } - } -} - -/// Pick a save location. -/// -/// Applies the same extension-validation logic as the open-picker functions: -/// the MIME type is converted to an extension, and `add_filter` is only called -/// if the resulting extension is valid for the native dialog. -/// -/// On Linux, if the XDG Desktop Portal is unavailable, falls back to zenity. -/// -/// # Errors -/// -/// Returns [`PickerError`] if the platform dialog could not be presented. -/// -/// # Examples -/// -/// ```no_run -/// # async fn example() -> Result<(), loki_file_access::PickerError> { -/// use loki_file_access::{FilePicker, SaveOptions}; -/// let token = FilePicker::new() -/// .pick_file_to_save(SaveOptions { -/// mime_type: Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document".into()), -/// suggested_name: Some("report.docx".into()), -/// }) -/// .await?; -/// # Ok(()) } -/// ``` -pub(crate) async fn pick_save( - options: SaveOptions, -) -> Result, PickerError> { - check_backend_env()?; - - let suggested_name = options.suggested_name; - let filter_label = "File".to_owned(); - let filter_exts: Vec = if let Some(ref mime) = options.mime_type { - mime_types_to_extensions(std::slice::from_ref(mime)) - .into_iter() - .filter(|e| is_valid_extension(e)) - .collect() - } else { - vec![] - }; - - let mut dialog = rfd::AsyncFileDialog::new(); - if let Some(ref name) = suggested_name { - dialog = dialog.set_file_name(name); - } - if !filter_exts.is_empty() { - let ext_refs: Vec<&str> = filter_exts.iter().map(String::as_str).collect(); - dialog = dialog.add_filter(&filter_label, &ext_refs); - } - - match dialog.save_file().await { - Some(h) => { - let path = h.path().to_path_buf(); - let display_name = file_name_from_path(&path); - Ok(Some(FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - })) - } - None => { - #[cfg(target_os = "linux")] - { - if zenity::is_zenity_available() { - tracing::debug!("XDG Desktop Portal unavailable; falling back to zenity"); - let (fut, state) = crate::future::new_pick_future(); - std::thread::spawn(move || { - let result = zenity::pick_save( - "Save File".into(), - suggested_name, - filter_label, - filter_exts, - ) - .map(|opt| { - opt.map(|path| { - let display_name = file_name_from_path(&path); - FileAccessToken { - inner: TokenInner::Desktop { path, display_name }, - } - }) - }); - crate::future::deliver(&state, result); - }); - return fut.await; - } - tracing::warn!( - "Save-file picker returned no result. \ - On ChromeOS Crostini or minimal Linux environments, \ - install zenity for file dialog support: sudo apt install zenity" - ); - } - Ok(None) - } - } -} - -/// Open a token for reading. -pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Desktop { path, .. } => { - let file = std::fs::File::open(path)?; - Ok(Box::new(file)) - } - _ => Err(AccessError::Platform { - message: "non-desktop token on desktop platform".into(), - }), - } -} - -/// Open a token for writing. -pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Desktop { path, .. } => { - let file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .open(path)?; - Ok(Box::new(file)) - } - _ => Err(AccessError::Platform { - message: "non-desktop token on desktop platform".into(), - }), - } -} - -/// Delete the file referenced by a token. -pub(crate) fn delete(inner: &TokenInner) -> Result<(), AccessError> { - match inner { - TokenInner::Desktop { path, .. } => { - std::fs::remove_file(path)?; - Ok(()) - } - _ => Err(AccessError::Platform { - message: "non-desktop token on desktop platform".into(), - }), - } -} - -/// Check permission status for a token. -pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { - match inner { - TokenInner::Desktop { path, .. } => { - if path.exists() { - PermissionStatus::Valid - } else { - PermissionStatus::Revoked - } - } - _ => PermissionStatus::Unknown, - } -} - -/// Extract a display name from a filesystem path. -fn file_name_from_path(path: &std::path::Path) -> String { - path.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed") - .to_owned() -} diff --git a/patches/loki-file-access/src/platform/desktop/zenity.rs b/patches/loki-file-access/src/platform/desktop/zenity.rs deleted file mode 100644 index 1e037b0a..00000000 --- a/patches/loki-file-access/src/platform/desktop/zenity.rs +++ /dev/null @@ -1,121 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Zenity subprocess fallback for Linux environments where the XDG Desktop -//! Portal is unavailable (e.g. ChromeOS Crostini). -//! -//! Requires `zenity` to be installed: `sudo apt install zenity` - -use std::path::PathBuf; -use std::process::Command; - -use crate::error::PickerError; - -/// Returns `true` if zenity is installed and executable. -pub(super) fn is_zenity_available() -> bool { - Command::new("zenity") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -/// Open a single-file picker via zenity. -/// -/// Returns `Ok(Some(path))` on selection, `Ok(None)` on cancel. -pub(super) fn pick_file( - title: String, - filter_label: String, - filter_exts: Vec, -) -> Result, PickerError> { - let mut cmd = build_open_cmd(&filter_label, &filter_exts); - cmd.arg("--title").arg(title); - run_single(cmd) -} - -/// Open a multi-file picker via zenity. -/// -/// Returns `Ok(vec)` of selected paths, empty on cancel. -pub(super) fn pick_files( - title: String, - filter_label: String, - filter_exts: Vec, -) -> Result, PickerError> { - let mut cmd = build_open_cmd(&filter_label, &filter_exts); - cmd.arg("--multiple"); - cmd.arg("--separator").arg("\n"); - cmd.arg("--title").arg(title); - run_multi(cmd) -} - -/// Open a save-file dialog via zenity. -/// -/// Returns `Ok(Some(path))` on selection, `Ok(None)` on cancel. -pub(super) fn pick_save( - title: String, - default_name: Option, - filter_label: String, - filter_exts: Vec, -) -> Result, PickerError> { - let mut cmd = build_open_cmd(&filter_label, &filter_exts); - cmd.arg("--save"); - cmd.arg("--confirm-overwrite"); - cmd.arg("--title").arg(title); - if let Some(name) = default_name { - cmd.arg("--filename").arg(name); - } - run_single(cmd) -} - -/// Build a base `zenity --file-selection` command with an optional file filter. -fn build_open_cmd(filter_label: &str, filter_exts: &[String]) -> Command { - let mut cmd = Command::new("zenity"); - cmd.arg("--file-selection"); - if !filter_exts.is_empty() { - // zenity --file-filter format: "Label | *.ext1 *.ext2" - let pattern = filter_exts - .iter() - .map(|e| format!("*.{}", e.trim_start_matches('.'))) - .collect::>() - .join(" "); - cmd.arg("--file-filter") - .arg(format!("{filter_label} | {pattern}")); - } - cmd -} - -/// Run zenity and parse a single path from stdout. -/// -/// Exit code 1 means the user cancelled — returned as `Ok(None)`, not an error. -fn run_single(mut cmd: Command) -> Result, PickerError> { - let output = cmd.output().map_err(|e| PickerError::Platform { - message: format!("failed to launch zenity: {e}"), - })?; - if !output.status.success() { - return Ok(None); - } - let path = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - if path.is_empty() { - Ok(None) - } else { - Ok(Some(PathBuf::from(path))) - } -} - -/// Run zenity and parse newline-separated paths from stdout. -/// -/// Exit code 1 means the user cancelled — returned as `Ok(vec![])`, not an error. -fn run_multi(mut cmd: Command) -> Result, PickerError> { - let output = cmd.output().map_err(|e| PickerError::Platform { - message: format!("failed to launch zenity: {e}"), - })?; - if !output.status.success() { - return Ok(vec![]); - } - let paths = String::from_utf8_lossy(&output.stdout) - .lines() - .filter(|l| !l.is_empty()) - .map(PathBuf::from) - .collect(); - Ok(paths) -} diff --git a/patches/loki-file-access/src/platform/ios/bookmark.rs b/patches/loki-file-access/src/platform/ios/bookmark.rs deleted file mode 100644 index 02bb6fc7..00000000 --- a/patches/loki-file-access/src/platform/ios/bookmark.rs +++ /dev/null @@ -1,118 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! iOS security-scoped bookmark helpers. -//! -//! This submodule handles creating, resolving, and managing security-scoped -//! bookmarks for iOS file access. It also provides the [`ScopedBookmarkFile`] -//! RAII guard that calls `stopAccessingSecurityScopedResource()` on drop. - -use crate::error::{AccessError, PickerError}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// Create a [`FileAccessToken`] from an iOS file URL string. -/// -/// This function calls `startAccessingSecurityScopedResource()`, creates a -/// bookmark via `NSURL.bookmarkData(...)`, then calls -/// `stopAccessingSecurityScopedResource()`. -pub(super) fn token_from_url(url: &str) -> Result { - // In a full implementation: - // 1. Create NSURL from the string - // 2. Call startAccessingSecurityScopedResource() - // 3. Create bookmark data via bookmarkData(options:...) - // 4. Call stopAccessingSecurityScopedResource() - // 5. Extract the display name from the URL's lastPathComponent - let display_name = url.rsplit('/').next().unwrap_or("unnamed").to_owned(); - - Ok(FileAccessToken { - inner: TokenInner::Ios { - bookmark: url.as_bytes().to_vec(), - display_name, - mime_type: None, - }, - }) -} - -/// Open a security-scoped bookmark for reading. -/// -/// Resolves the bookmark to a URL, starts accessing the security-scoped -/// resource, and opens the file. Returns a [`ScopedBookmarkFile`] that -/// stops access on drop. -pub(super) fn open_read_bookmark(bookmark: &[u8]) -> Result, AccessError> { - // In a full implementation: - // 1. Resolve bookmark via NSURL.init(resolvingBookmarkData:...) - // 2. Call startAccessingSecurityScopedResource() - // 3. Open the file path for reading - // 4. Wrap in ScopedBookmarkFile - let _url = String::from_utf8(bookmark.to_vec()).map_err(|_| AccessError::Platform { - message: "invalid bookmark data".into(), - })?; - - Err(AccessError::Platform { - message: "iOS bookmark resolution requires Objective-C runtime".into(), - }) -} - -/// Open a security-scoped bookmark for writing. -pub(super) fn open_write_bookmark(bookmark: &[u8]) -> Result, AccessError> { - let _url = String::from_utf8(bookmark.to_vec()).map_err(|_| AccessError::Platform { - message: "invalid bookmark data".into(), - })?; - - Err(AccessError::Platform { - message: "iOS bookmark resolution requires Objective-C runtime".into(), - }) -} - -/// Check whether a bookmark can still be resolved. -pub(super) fn check_bookmark(bookmark: &[u8]) -> PermissionStatus { - // In a full implementation this would attempt to resolve the bookmark - // and check whether it is stale. - if bookmark.is_empty() { - PermissionStatus::Revoked - } else { - PermissionStatus::Unknown - } -} - -/// RAII guard that calls `stopAccessingSecurityScopedResource()` on drop. -/// -/// Wraps a `std::fs::File` and the URL handle so that the security scope -/// is released when the file is closed. -#[allow(dead_code)] // Used in full iOS implementation, placeholder here -pub(super) struct ScopedBookmarkFile { - /// The underlying file handle. - file: std::fs::File, - /// The URL string, retained for `stopAccessingSecurityScopedResource`. - url: String, -} - -impl std::io::Read for ScopedBookmarkFile { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - self.file.read(buf) - } -} - -impl std::io::Write for ScopedBookmarkFile { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.file.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.file.flush() - } -} - -impl std::io::Seek for ScopedBookmarkFile { - fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { - self.file.seek(pos) - } -} - -impl Drop for ScopedBookmarkFile { - fn drop(&mut self) { - // In a full implementation: - // Resolve self.url back to NSURL and call - // stopAccessingSecurityScopedResource(). - } -} diff --git a/patches/loki-file-access/src/platform/ios/mod.rs b/patches/loki-file-access/src/platform/ios/mod.rs deleted file mode 100644 index 825cdba7..00000000 --- a/patches/loki-file-access/src/platform/ios/mod.rs +++ /dev/null @@ -1,132 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! iOS file-picker implementation using `UIDocumentPickerViewController`. -//! -//! This module presents the system document picker via UIKit and manages -//! security-scoped bookmarks for persistent file access. Each selected URL -//! is converted to a bookmark that can be stored and resolved across app -//! restarts, as required by Apple's security-scoped resource model. -//! -//! # Security-Scoped Resources -//! -//! iOS requires calling `startAccessingSecurityScopedResource()` before -//! opening a bookmarked URL and `stopAccessingSecurityScopedResource()` when -//! done. The [`ScopedBookmarkFile`] RAII guard handles this automatically. - -mod bookmark; - -use std::sync::Arc; - -use crate::api::{PickOptions, SaveOptions}; -use crate::error::{AccessError, PickerError}; -use crate::future::{deliver, new_pick_future}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// Pick a single file for reading. -pub(crate) async fn pick_open_single( - options: PickOptions, -) -> Result, PickerError> { - let urls = present_picker(&options, false).await?; - match urls.into_iter().next() { - None => Ok(None), - Some(url) => { - let token = bookmark::token_from_url(&url)?; - Ok(Some(token)) - } - } -} - -/// Pick multiple files for reading. -pub(crate) async fn pick_open_multi( - options: PickOptions, -) -> Result, PickerError> { - let urls = present_picker(&options, true).await?; - urls.iter() - .map(|url| bookmark::token_from_url(url)) - .collect() -} - -/// Pick a save location. -pub(crate) async fn pick_save( - options: SaveOptions, -) -> Result, PickerError> { - let url = present_save_picker(&options).await?; - match url { - None => Ok(None), - Some(u) => { - let token = bookmark::token_from_url(&u)?; - Ok(Some(token)) - } - } -} - -/// Open a bookmarked file for reading. -pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Ios { bookmark, .. } => bookmark::open_read_bookmark(bookmark), - _ => Err(AccessError::Platform { - message: "non-iOS token on iOS platform".into(), - }), - } -} - -/// Open a bookmarked file for writing. -pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Ios { bookmark, .. } => bookmark::open_write_bookmark(bookmark), - _ => Err(AccessError::Platform { - message: "non-iOS token on iOS platform".into(), - }), - } -} - -/// Delete the file referenced by a token. -/// -/// Deleting a security-scoped bookmarked file is not currently implemented. -/// Return an explicit unsupported error rather than silently succeeding. -pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { - Err(AccessError::Unsupported { - operation: "delete (iOS bookmarked-file deletion not implemented)".into(), - }) -} - -/// Check whether a bookmark is still resolvable. -pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { - match inner { - TokenInner::Ios { bookmark, .. } => bookmark::check_bookmark(bookmark), - _ => PermissionStatus::Unknown, - } -} - -/// Present the document picker for opening files. -async fn present_picker( - _options: &PickOptions, - _allow_multiple: bool, -) -> Result, PickerError> { - let (future, state) = new_pick_future::>(); - let state_clone = Arc::clone(&state); - - // The delegate callback will call `deliver` with the selected URLs. - // In a full implementation this would use `objc2::declare_class!` to - // create a `UIDocumentPickerDelegate` and present the picker on the - // root view controller. The delegate's - // `documentPicker:didPickDocumentsAtURLs:` method would extract the - // URL strings and call `deliver(&state_clone, urls)`. - // - // Placeholder: deliver an empty result to avoid hanging. - deliver(&state_clone, vec![]); - - Ok(future.await) -} - -/// Present the document picker for saving a file. -async fn present_save_picker(_options: &SaveOptions) -> Result, PickerError> { - let (future, state) = new_pick_future::>(); - let state_clone = Arc::clone(&state); - - // Same placeholder approach as `present_picker`. - deliver(&state_clone, None); - - Ok(future.await) -} diff --git a/patches/loki-file-access/src/platform/mod.rs b/patches/loki-file-access/src/platform/mod.rs deleted file mode 100644 index 75e84e91..00000000 --- a/patches/loki-file-access/src/platform/mod.rs +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Platform dispatch layer. -//! -//! This module selects the appropriate platform implementation at compile time -//! using `cfg` attributes and re-exports the four operations that the public -//! API delegates to: -//! -//! - [`pick_open_single`] — pick one file for reading -//! - [`pick_open_multi`] — pick multiple files for reading -//! - [`pick_save`] — pick a save location -//! - [`open_read`] / [`open_write`] — open a token for I/O -//! - [`check_permission`] — query whether a token is still valid - -#[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] -mod desktop; -#[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] -pub(crate) use desktop::{ - check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, -}; - -#[cfg(target_os = "android")] -mod android; -#[cfg(target_os = "android")] -pub(crate) use android::{ - check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, -}; -#[cfg(target_os = "android")] -pub use android::{ - init_android, install_ime_listener, on_activity_result, query_insets_dp, query_window_insets_dp, - set_ime_visibility_listener, -}; - -#[cfg(target_os = "ios")] -mod ios; -#[cfg(target_os = "ios")] -pub(crate) use ios::{ - check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, -}; - -#[cfg(target_arch = "wasm32")] -mod wasm; -#[cfg(target_arch = "wasm32")] -pub(crate) use wasm::{ - check_permission, delete, open_read, open_write, pick_open_multi, pick_open_single, pick_save, -}; - -// Ensure a compile error on unsupported platforms rather than silent failure. -#[cfg(not(any( - target_os = "android", - target_os = "ios", - target_arch = "wasm32", - target_os = "windows", - target_os = "macos", - target_os = "linux", - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "dragonfly", -)))] -compile_error!( - "loki-file-access: unsupported target platform. \ - Supported: Windows, macOS, Linux, BSD, Android, iOS, WASM." -); diff --git a/patches/loki-file-access/src/platform/wasm.rs b/patches/loki-file-access/src/platform/wasm.rs deleted file mode 100644 index 38f00386..00000000 --- a/patches/loki-file-access/src/platform/wasm.rs +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! WASM file-picker implementation using ``. -//! -//! This module creates a hidden `` element in the DOM, -//! configures it with MIME-type filters and multi-select options, and listens -//! for the `change` event to capture the user's selection. Selected files are -//! read entirely into memory as `Vec`. -//! -//! # Save behaviour -//! -//! WASM has no traditional save dialog. [`pick_save`] triggers a browser -//! download via a Blob URL. The returned [`crate::FileAccessToken`] wraps an -//! in-memory buffer that the caller can write to before the download is -//! initiated. This is a fundamental platform limitation documented in the -//! public [`crate::FilePicker::pick_file_to_save`] doc comment. -//! -//! # Persistence -//! -//! WASM tokens hold file data in memory and do not survive page reloads. -//! Serialising a WASM token preserves the data, but restoring it on a new -//! page load gives back the original bytes — there is no way to re-acquire -//! filesystem access. - -use crate::api::{PickOptions, SaveOptions}; -use crate::error::{AccessError, PickerError}; -use crate::token::{FileAccessToken, PermissionStatus, ReadSeek, TokenInner, WriteSeek}; - -/// Pick a single file for reading. -pub(crate) async fn pick_open_single( - options: PickOptions, -) -> Result, PickerError> { - let tokens = pick_files(options, false).await?; - Ok(tokens.into_iter().next()) -} - -/// Pick multiple files for reading. -pub(crate) async fn pick_open_multi( - options: PickOptions, -) -> Result, PickerError> { - pick_files(options, true).await -} - -/// Trigger a browser download. -/// -/// On WASM there is no save dialog — this creates an empty in-memory buffer -/// token. The caller should write data into it, then call a platform-specific -/// download trigger (e.g. creating a Blob URL and clicking an anchor element). -pub(crate) async fn pick_save( - options: SaveOptions, -) -> Result, PickerError> { - let name = options.suggested_name.unwrap_or_else(|| "download".into()); - Ok(Some(FileAccessToken { - inner: TokenInner::Wasm { - data: Vec::new(), - name, - mime_type: options.mime_type, - }, - })) -} - -/// Open a WASM token for reading (reads from the in-memory buffer). -pub(crate) fn open_read(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Wasm { data, .. } => Ok(Box::new(std::io::Cursor::new(data.clone()))), - _ => Err(AccessError::Platform { - message: "non-WASM token on WASM platform".into(), - }), - } -} - -/// Open a WASM token for writing (writes to an in-memory buffer). -pub(crate) fn open_write(inner: &TokenInner) -> Result, AccessError> { - match inner { - TokenInner::Wasm { data, .. } => Ok(Box::new(std::io::Cursor::new(data.clone()))), - _ => Err(AccessError::Platform { - message: "non-WASM token on WASM platform".into(), - }), - } -} - -/// Delete the file referenced by a token. -/// -/// WASM tokens wrap an in-memory buffer with no backing filesystem, so there -/// is nothing to delete. Return an explicit unsupported error. -pub(crate) fn delete(_inner: &TokenInner) -> Result<(), AccessError> { - Err(AccessError::Unsupported { - operation: "delete (WASM in-memory tokens have no backing file)".into(), - }) -} - -/// WASM tokens are always valid while the page is loaded. -pub(crate) fn check_permission(inner: &TokenInner) -> PermissionStatus { - match inner { - TokenInner::Wasm { .. } => PermissionStatus::Valid, - _ => PermissionStatus::Unknown, - } -} - -/// Create and trigger a hidden `` element. -async fn pick_files( - options: PickOptions, - multiple: bool, -) -> Result, PickerError> { - use wasm_bindgen::JsCast as _; - use wasm_bindgen_futures::JsFuture; - - let window = web_sys::window().ok_or_else(|| PickerError::Platform { - message: "no global window object".into(), - })?; - let document = window.document().ok_or_else(|| PickerError::Platform { - message: "no document object".into(), - })?; - - let input: web_sys::HtmlInputElement = document - .create_element("input") - .map_err(|e| PickerError::Platform { - message: format!("create_element failed: {e:?}"), - })? - .dyn_into() - .map_err(|_| PickerError::Platform { - message: "element is not HtmlInputElement".into(), - })?; - - input.set_type("file"); - - if !options.mime_types.is_empty() { - input.set_accept(&options.mime_types.join(",")); - } - - if multiple { - input.set_multiple(true); - } - - // Create a promise that resolves when the change event fires. - let promise = js_sys::Promise::new(&mut |resolve, _reject| { - let resolve_clone = resolve.clone(); - let cb = wasm_bindgen::closure::Closure::once_into_js(move || { - let _ = resolve_clone.call0(&wasm_bindgen::JsValue::NULL); - }); - let _ = input.add_event_listener_with_callback("change", cb.as_ref().unchecked_ref()); - }); - - // Trigger the file dialog. - input.click(); - - // Wait for the user to select files. - JsFuture::from(promise) - .await - .map_err(|e| PickerError::Platform { - message: format!("file input promise rejected: {e:?}"), - })?; - - // Read selected files. - let file_list = match input.files() { - Some(fl) => fl, - None => return Ok(vec![]), - }; - - let mut tokens = Vec::new(); - for i in 0..file_list.length() { - let file = match file_list.get(i) { - Some(f) => f, - None => continue, - }; - let name = file.name(); - let mime = file.type_(); - let mime_type = if mime.is_empty() { None } else { Some(mime) }; - - let array_buf_promise = file.array_buffer(); - let array_buf = - JsFuture::from(array_buf_promise) - .await - .map_err(|e| PickerError::Platform { - message: format!("arrayBuffer() failed: {e:?}"), - })?; - let uint8 = js_sys::Uint8Array::new(&array_buf); - let data = uint8.to_vec(); - - tokens.push(FileAccessToken { - inner: TokenInner::Wasm { - data, - name, - mime_type, - }, - }); - } - - Ok(tokens) -} diff --git a/patches/loki-file-access/src/token.rs b/patches/loki-file-access/src/token.rs deleted file mode 100644 index b048a909..00000000 --- a/patches/loki-file-access/src/token.rs +++ /dev/null @@ -1,412 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Capability token for accessing user-selected files. -//! -//! [`FileAccessToken`] is the central type returned by every picker operation. -//! It encapsulates all platform-specific state needed to re-open a file, -//! including Android URIs, iOS security-scoped bookmarks, desktop paths, and -//! in-memory WASM data. -//! -//! Tokens are serializable to a URL-safe base64-encoded JSON string via -//! [`FileAccessToken::serialize`] and [`FileAccessToken::deserialize`], making -//! them suitable for persisting in a recent-files list or application database. - -use base64::Engine as _; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use std::path::PathBuf; - -use crate::error::{AccessError, TokenParseError}; - -/// Status of the permission grant associated with a [`FileAccessToken`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum PermissionStatus { - /// The token's permission is still valid and the file can be opened. - Valid, - /// The permission has been revoked by the user or the operating system. - Revoked, - /// The permission status cannot be determined on this platform. - Unknown, -} - -/// Internal representation of platform-specific token data. -/// -/// This enum is serialized to JSON and then base64-encoded for storage. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub(crate) enum TokenInner { - /// Desktop file identified by filesystem path. - Desktop { - /// Absolute path to the file. - path: PathBuf, - /// User-visible file name. - display_name: String, - }, - /// Android file identified by a content URI. - Android { - /// Content URI string (e.g. `content://...`). - uri: String, - /// User-visible file name from the document provider. - display_name: String, - /// MIME type reported by the document provider. - mime_type: Option, - }, - /// iOS file identified by a security-scoped bookmark. - Ios { - /// Opaque bookmark data created by `NSURL.bookmarkData(...)`. - bookmark: Vec, - /// User-visible file name. - display_name: String, - /// MIME type (often inferred from the file extension). - mime_type: Option, - }, - /// WASM file held entirely in memory. - Wasm { - /// Complete file contents. - data: Vec, - /// Original file name from the `` element. - name: String, - /// MIME type reported by the browser. - mime_type: Option, - }, -} - -/// A serializable capability token representing access to a user-selected file. -/// -/// Obtain instances from [`crate::FilePicker`] methods. Serialize via -/// [`serialize`](Self::serialize) for storage; deserialize to reopen files -/// across app restarts. -#[derive(Debug, Clone)] -pub struct FileAccessToken { - pub(crate) inner: TokenInner, -} - -impl FileAccessToken { - /// Open the file for reading. Returns `Read + Seek`. - /// - /// # Errors - /// - /// Returns [`AccessError`] if permission is revoked or the file cannot be opened. - #[must_use = "this returns a Result that may contain an error"] - pub fn open_read(&self) -> Result, AccessError> { - crate::platform::open_read(&self.inner) - } - - /// Open the file for writing. Returns `Write + Seek`. - /// - /// # Errors - /// - /// Returns [`AccessError`] if permission is revoked or the file cannot be opened. - #[must_use = "this returns a Result that may contain an error"] - pub fn open_write(&self) -> Result, AccessError> { - crate::platform::open_write(&self.inner) - } - - /// Delete the underlying file. - /// - /// On desktop this removes the file from the filesystem. On platforms - /// where deletion is not yet implemented (Android, iOS, WASM) this returns - /// [`AccessError::Unsupported`] rather than silently succeeding. - /// - /// # Errors - /// - /// Returns [`AccessError`] if the file cannot be deleted or deletion is - /// not supported on the current platform. - #[must_use = "this returns a Result that may contain an error"] - pub fn delete(&self) -> Result<(), AccessError> { - crate::platform::delete(&self.inner) - } - - /// Copy the full contents of this file into `dest`. - /// - /// Reads all bytes from `self` via [`open_read`](Self::open_read) and writes - /// them to `dest` via [`open_write`](Self::open_write). This works across - /// every platform because it relies only on the token I/O primitives, not - /// on filesystem paths. - /// - /// # Errors - /// - /// Returns [`AccessError`] if either token cannot be opened or an I/O error - /// occurs while transferring bytes. - #[must_use = "this returns a Result that may contain an error"] - pub fn copy_bytes_to(&self, dest: &FileAccessToken) -> Result<(), AccessError> { - use std::io::{Read as _, Write as _}; - - let mut reader = self.open_read()?; - let mut bytes = Vec::new(); - reader.read_to_end(&mut bytes)?; - - let mut writer = dest.open_write()?; - writer.write_all(&bytes)?; - writer.flush()?; - Ok(()) - } - - /// Returns the user-visible display name of the file (typically the filename). - #[must_use] - pub fn display_name(&self) -> &str { - match &self.inner { - TokenInner::Desktop { display_name, .. } - | TokenInner::Android { display_name, .. } - | TokenInner::Ios { display_name, .. } => display_name, - TokenInner::Wasm { name, .. } => name, - } - } - - /// Returns the MIME type of the file, if known. Desktop returns `None`. - #[must_use] - pub fn mime_type(&self) -> Option<&str> { - match &self.inner { - TokenInner::Desktop { .. } => None, - TokenInner::Android { mime_type, .. } - | TokenInner::Ios { mime_type, .. } - | TokenInner::Wasm { mime_type, .. } => mime_type.as_deref(), - } - } - - /// Check whether the permission grant for this file is still valid. - #[must_use] - pub fn check_permission(&self) -> PermissionStatus { - crate::platform::check_permission(&self.inner) - } - - /// Serialize the token to a URL-safe base64-encoded string for storage. - #[must_use] - pub fn serialize(&self) -> String { - // Serialization of the inner enum to JSON should not fail for our - // data types (no maps with non-string keys, no infinite floats). - // However, we handle the error path gracefully by returning an - // empty-object JSON fallback, which will fail on deserialization - // with a clear error rather than panicking here. - let json = match serde_json::to_string(&self.inner) { - Ok(j) => j, - Err(_) => return URL_SAFE_NO_PAD.encode(b"{}"), - }; - URL_SAFE_NO_PAD.encode(json.as_bytes()) - } - - /// Deserialize a token from a string previously returned by [`serialize`](Self::serialize). - /// - /// # Errors - /// - /// Returns [`TokenParseError`] if the string is malformed. - pub fn deserialize(s: &str) -> Result { - let bytes = URL_SAFE_NO_PAD - .decode(s) - .map_err(|e| TokenParseError::InvalidBase64 { - message: e.to_string(), - })?; - - let json = String::from_utf8(bytes).map_err(|e| TokenParseError::InvalidBase64 { - message: e.to_string(), - })?; - - let inner: TokenInner = - serde_json::from_str(&json).map_err(|e| TokenParseError::InvalidJson { - message: e.to_string(), - })?; - - Ok(Self { inner }) - } -} - -/// Trait object combining [`std::io::Read`] and [`std::io::Seek`]. -pub trait ReadSeek: std::io::Read + std::io::Seek + Send {} -impl ReadSeek for T {} - -/// Trait object combining [`std::io::Write`] and [`std::io::Seek`]. -pub trait WriteSeek: std::io::Write + std::io::Seek + Send {} -impl WriteSeek for T {} - -impl std::fmt::Display for FileAccessToken { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.serialize()) - } -} - -impl std::str::FromStr for FileAccessToken { - type Err = TokenParseError; - - fn from_str(s: &str) -> Result { - Self::deserialize(s) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn round_trip_desktop_token() { - let token = FileAccessToken { - inner: TokenInner::Desktop { - path: PathBuf::from("/tmp/test.txt"), - display_name: "test.txt".into(), - }, - }; - let serialized = token.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - assert_eq!(restored.display_name(), "test.txt"); - assert!(restored.mime_type().is_none()); - } - - #[test] - fn round_trip_android_token() { - let token = FileAccessToken { - inner: TokenInner::Android { - uri: "content://com.example/doc/1".into(), - display_name: "photo.jpg".into(), - mime_type: Some("image/jpeg".into()), - }, - }; - let serialized = token.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - assert_eq!(restored.display_name(), "photo.jpg"); - assert_eq!(restored.mime_type(), Some("image/jpeg")); - } - - #[test] - fn round_trip_ios_token() { - let token = FileAccessToken { - inner: TokenInner::Ios { - bookmark: vec![0xDE, 0xAD, 0xBE, 0xEF], - display_name: "notes.pdf".into(), - mime_type: Some("application/pdf".into()), - }, - }; - let serialized = token.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - assert_eq!(restored.display_name(), "notes.pdf"); - assert_eq!(restored.mime_type(), Some("application/pdf")); - } - - #[test] - fn round_trip_wasm_token() { - let token = FileAccessToken { - inner: TokenInner::Wasm { - data: vec![1, 2, 3, 4, 5], - name: "data.bin".into(), - mime_type: Some("application/octet-stream".into()), - }, - }; - let serialized = token.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - assert_eq!(restored.display_name(), "data.bin"); - assert_eq!(restored.mime_type(), Some("application/octet-stream")); - } - - #[test] - fn deserialize_invalid_base64_returns_error() { - let result = FileAccessToken::deserialize("not!valid!base64!!!"); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - TokenParseError::InvalidBase64 { .. } - )); - } - - #[test] - fn deserialize_invalid_json_returns_error() { - let bad = URL_SAFE_NO_PAD.encode(b"not json"); - let result = FileAccessToken::deserialize(&bad); - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - TokenParseError::InvalidJson { .. } - )); - } - - // The following tests exercise the platform-routed operations. They only - // run on desktop targets, where the desktop backend is compiled in. - #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - fn desktop_token_for(path: &std::path::Path) -> FileAccessToken { - FileAccessToken { - inner: TokenInner::Desktop { - path: path.to_path_buf(), - display_name: path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed") - .to_owned(), - }, - } - } - - #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - #[test] - fn desktop_delete_removes_file() { - let dir = std::env::temp_dir(); - let path = dir.join(format!("loki_delete_test_{}.txt", std::process::id())); - std::fs::write(&path, b"hello").expect("write temp file"); - assert!(path.exists()); - - let token = desktop_token_for(&path); - token.delete().expect("delete should succeed"); - assert!(!path.exists(), "file must be gone after delete"); - } - - #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - #[test] - fn desktop_delete_missing_file_is_io_error() { - let dir = std::env::temp_dir(); - let path = dir.join(format!("loki_delete_missing_{}.txt", std::process::id())); - let _ = std::fs::remove_file(&path); - - let token = desktop_token_for(&path); - let err = token - .delete() - .expect_err("deleting a missing file must error"); - assert!(matches!(err, AccessError::Io { .. })); - } - - #[cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - #[test] - fn desktop_copy_bytes_to_duplicates_contents() { - let dir = std::env::temp_dir(); - let pid = std::process::id(); - let src_path = dir.join(format!("loki_copy_src_{pid}.txt")); - let dest_path = dir.join(format!("loki_copy_dest_{pid}.txt")); - std::fs::write(&src_path, b"copy me").expect("write source"); - let _ = std::fs::remove_file(&dest_path); - - let src = desktop_token_for(&src_path); - let dest = desktop_token_for(&dest_path); - src.copy_bytes_to(&dest).expect("copy should succeed"); - - let copied = std::fs::read(&dest_path).expect("read dest"); - assert_eq!(copied, b"copy me"); - - let _ = std::fs::remove_file(&src_path); - let _ = std::fs::remove_file(&dest_path); - } - - // On non-desktop targets the unsupported-platform path is taken: deleting - // any token must surface AccessError::Unsupported rather than succeed. - #[cfg(any(target_os = "android", target_os = "ios", target_arch = "wasm32"))] - #[test] - fn non_desktop_delete_is_unsupported() { - let token = FileAccessToken { - inner: TokenInner::Desktop { - path: PathBuf::from("/tmp/whatever.txt"), - display_name: "whatever.txt".into(), - }, - }; - let err = token - .delete() - .expect_err("delete must be unsupported off-desktop"); - assert!(matches!(err, AccessError::Unsupported { .. })); - } - - #[test] - fn display_and_from_str_round_trip() { - let token = FileAccessToken { - inner: TokenInner::Desktop { - path: PathBuf::from("/tmp/x.txt"), - display_name: "x.txt".into(), - }, - }; - let s = token.to_string(); - let restored: FileAccessToken = s.parse().unwrap(); - assert_eq!(restored.display_name(), "x.txt"); - } -} diff --git a/patches/loki-file-access/tests/desktop.rs b/patches/loki-file-access/tests/desktop.rs deleted file mode 100644 index e10c8e87..00000000 --- a/patches/loki-file-access/tests/desktop.rs +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 AppThere - -//! Integration tests for desktop file access. -//! -//! These tests are cfg-gated to run only on desktop platforms (not Android, -//! iOS, or WASM) where direct filesystem path access is available. - -#![cfg(not(any(target_os = "android", target_os = "ios", target_arch = "wasm32")))] - -use loki_file_access::{FileAccessToken, PermissionStatus}; -use std::io::{Read, Seek, Write}; - -#[test] -fn open_read_from_temp_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("test.txt"); - std::fs::write(&path, "hello world").unwrap(); - - let token = FileAccessToken::deserialize( - &create_desktop_token(&path, "test.txt").serialize(), - ) - .unwrap(); - - let mut reader = token.open_read().unwrap(); - let mut contents = String::new(); - reader.read_to_string(&mut contents).unwrap(); - assert_eq!(contents, "hello world"); -} - -#[test] -fn open_write_to_temp_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("output.txt"); - std::fs::write(&path, "").unwrap(); - - let token = create_desktop_token(&path, "output.txt"); - - { - let mut writer = token.open_write().unwrap(); - writer.write_all(b"written data").unwrap(); - } - - let contents = std::fs::read_to_string(&path).unwrap(); - assert_eq!(contents, "written data"); -} - -#[test] -fn check_permission_returns_valid_for_existing_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("exists.txt"); - std::fs::write(&path, "data").unwrap(); - - let token = create_desktop_token(&path, "exists.txt"); - assert_eq!(token.check_permission(), PermissionStatus::Valid); -} - -#[test] -fn check_permission_returns_revoked_for_missing_file() { - let token = create_desktop_token( - std::path::Path::new("/tmp/nonexistent_loki_test_file_12345.txt"), - "nonexistent.txt", - ); - assert_eq!(token.check_permission(), PermissionStatus::Revoked); -} - -#[test] -fn token_round_trip_serialization() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("roundtrip.txt"); - std::fs::write(&path, "round-trip content").unwrap(); - - let original = create_desktop_token(&path, "roundtrip.txt"); - let serialized = original.serialize(); - let restored = FileAccessToken::deserialize(&serialized).unwrap(); - - assert_eq!(restored.display_name(), "roundtrip.txt"); - - let mut reader = restored.open_read().unwrap(); - let mut contents = String::new(); - reader.read_to_string(&mut contents).unwrap(); - assert_eq!(contents, "round-trip content"); -} - -#[test] -fn display_name_returns_filename() { - let token = create_desktop_token( - std::path::Path::new("/tmp/example.csv"), - "example.csv", - ); - assert_eq!(token.display_name(), "example.csv"); -} - -#[test] -fn mime_type_is_none_for_desktop() { - let token = create_desktop_token( - std::path::Path::new("/tmp/file.txt"), - "file.txt", - ); - assert!(token.mime_type().is_none()); -} - -#[test] -fn open_read_seek_rewind() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("seek.txt"); - std::fs::write(&path, "abcdef").unwrap(); - - let token = create_desktop_token(&path, "seek.txt"); - let mut reader = token.open_read().unwrap(); - - let mut buf = [0u8; 3]; - reader.read_exact(&mut buf).unwrap(); - assert_eq!(&buf, b"abc"); - - reader.seek(std::io::SeekFrom::Start(0)).unwrap(); - reader.read_exact(&mut buf).unwrap(); - assert_eq!(&buf, b"abc"); -} - -/// Helper to construct a desktop `FileAccessToken` directly. -/// -/// In production code, tokens are obtained from `FilePicker` methods. -/// For testing we construct them via the token's internal serialization format. -fn create_desktop_token(path: &std::path::Path, name: &str) -> FileAccessToken { - use base64::Engine as _; - - // Build the JSON payload that TokenInner::Desktop serializes to, - // then encode it to match FileAccessToken::serialize(). - let json = serde_json::json!({ - "Desktop": { - "path": path.to_str().unwrap(), - "display_name": name - } - }); - let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(json.to_string().as_bytes()); - FileAccessToken::deserialize(&encoded).unwrap() -} diff --git a/scripts/build-aab.sh b/scripts/build-aab.sh index 37ed69c7..7ca853a1 100755 --- a/scripts/build-aab.sh +++ b/scripts/build-aab.sh @@ -147,15 +147,28 @@ for i in "${!TRIPLES[@]}"; do echo " -> jniLibs/$abidir/libloki_text.so ($(du -h "$so" | cut -f1))" done -# ── Stage Java shims (single source of truth in patches/) ───────────────────── +# ── Stage Java shims (single source of truth: the loki-file-access crate) ───── + +# The shims live in the loki-file-access crate (a git dependency since the +# local patch was upstreamed, 2026-07-05); resolve its checkout directory +# from cargo metadata rather than hardcoding a path. +LFA_DIR="$(cd "$ROOT" && cargo metadata --format-version 1 | python3 -c ' +import json, os, sys +meta = json.load(sys.stdin) +for p in meta["packages"]: + if p["name"] == "loki-file-access": + print(os.path.dirname(p["manifest_path"])) + break +')" +[[ -n "$LFA_DIR" ]] || { echo "ERROR: loki-file-access not found in cargo metadata" >&2; exit 1; } JAVA_PKG_DIR="$ROOT/android/app/src/main/java/io/github/appthere/lokifileaccess" echo "" echo "==> Staging Java shims (FilePickerActivity, ImeInsetsListener)..." rm -rf "$ROOT/android/app/src/main/java" mkdir -p "$JAVA_PKG_DIR" -cp "$ROOT/patches/loki-file-access/android/FilePickerActivity.java" "$JAVA_PKG_DIR/" -cp "$ROOT/patches/loki-file-access/android/ImeInsetsListener.java" "$JAVA_PKG_DIR/" +cp "$LFA_DIR/android/FilePickerActivity.java" "$JAVA_PKG_DIR/" +cp "$LFA_DIR/android/ImeInsetsListener.java" "$JAVA_PKG_DIR/" # ── Gradle bundleRelease ────────────────────────────────────────────────────── diff --git a/scripts/build-android.ps1 b/scripts/build-android.ps1 index 666a92a0..4ef7e5ec 100644 --- a/scripts/build-android.ps1 +++ b/scripts/build-android.ps1 @@ -128,9 +128,16 @@ Write-Host "==> App: $App ($cargoPackage)" # ── Paths ───────────────────────────────────────────────────────────────────── $profile = if ($Release) { "release" } else { "debug" } +# The Java shims live in the loki-file-access crate (a git dependency since +# the local patch was upstreamed, 2026-07-05); resolve its checkout directory +# from cargo metadata rather than hardcoding a path. +$lfaPackage = (cargo metadata --format-version 1 | ConvertFrom-Json).packages | + Where-Object { $_.name -eq "loki-file-access" } | Select-Object -First 1 +if (-not $lfaPackage) { throw "loki-file-access not found in cargo metadata" } +$lfaDir = Split-Path $lfaPackage.manifest_path $javaSrcs = @( - "patches\loki-file-access\android\FilePickerActivity.java", - "patches\loki-file-access\android\ImeInsetsListener.java" + "$lfaDir\android\FilePickerActivity.java", + "$lfaDir\android\ImeInsetsListener.java" ) $outDir = "target\android-pkg" $apkSrc = "$PWD\target\$profile\apk\$apkBaseName.apk" diff --git a/scripts/build-android.sh b/scripts/build-android.sh index 61a939ff..b4c406fd 100755 --- a/scripts/build-android.sh +++ b/scripts/build-android.sh @@ -204,7 +204,19 @@ echo "==> javac: $JAVAC" # ── Paths ───────────────────────────────────────────────────────────────────── PROFILE="$([ "$RELEASE" -eq 1 ] && echo "release" || echo "debug")" -JAVA_SRC_DIR="patches/loki-file-access/android" +# The Java shims live in the loki-file-access crate (a git dependency since +# the local patch was upstreamed, 2026-07-05); resolve its checkout directory +# from cargo metadata rather than hardcoding a path. +LFA_DIR="$(cargo metadata --format-version 1 | python3 -c ' +import json, os, sys +meta = json.load(sys.stdin) +for p in meta["packages"]: + if p["name"] == "loki-file-access": + print(os.path.dirname(p["manifest_path"])) + break +')" +[[ -n "$LFA_DIR" ]] || { echo "ERROR: loki-file-access not found in cargo metadata" >&2; exit 1; } +JAVA_SRC_DIR="$LFA_DIR/android" JAVA_SRCS=("$JAVA_SRC_DIR/FilePickerActivity.java" "$JAVA_SRC_DIR/ImeInsetsListener.java") MANIFEST_XML="loki-text/AndroidManifest.xml" OUT_DIR="target/android-pkg" From 0e3854d1c5d955a0efdf9bf6f3cfef81533b1d37 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:07:52 +0000 Subject: [PATCH 08/27] feat(conformance): bundle Gelasio + vendor pinned schemas; exports schema-validated (Spec 02 B-10 + M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1 — Gelasio (B-10): - Bundle the missing metric-compatible Georgia face: Gelasio Regular/Bold/Italic/BoldItalic in loki-fonts/fonts/ (SIL OFL 1.1, no Reserved Font Name; license in OFL-Gelasio.txt). Reconstructed at full upstream coverage (791 codepoints) from the @fontsource/gelasio npm distribution by merging its latin + latin-ext + vietnamese subsets with fonttools. - Register Georgia → Gelasio in the substitution engine (loki-layout/src/font.rs) and embed the faces in fallback_font_blobs. - New dedicated substitution suite (Spec 02 §7.3/D4): loki-layout/tests/font_substitution_suite.rs — proprietary names resolve to bundled substitutes, substitutions are recorded for the warning banner, resolved families are actually layout-usable. Phase 3.2 — schemas (B-6 remainder, M2): - Vendored sets (each with a PROVENANCE.txt: source, version, license, per-file sha256): ISO/IEC 29500-4:2016 Transitional XSDs + mce/mc.xsd; ECMA-376 Part-2 OPC XSDs; OASIS ODF 1.3 OS RELAX NG (schema/manifest/ dsig) + W3C MathML3 RNG (via Maven org.odftoolkit:odfvalidator:0.12.0). - loki-odf/tests/schema_validation.rs: real ODT exports validate against the official ODF 1.3 schema — content.xml, styles.xml, meta.xml, and META-INF/manifest.xml all pass; a deliberately malformed part fails the gate (M2 acceptance). - loki-ooxml/tests/schema_validation.rs: real DOCX exports validate against ISO 29500 Transitional — word/document.xml + word/styles.xml pass, plus the OPC layer ([Content_Types].xml, _rels/.rels) against ECMA-376 Part 2; malformed part fails. Schema paths are canonicalized (libxml2 treats two spellings of one path as duplicate schema docs). - Documented tails: Strict XSDs and the Dublin Core imports of opc-coreProperties.xsd (no in-policy source reachable) — see schemas/README.md. Phases 3.1 + 3.2 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- appthere-conformance/schemas/README.md | 53 +- .../schemas/mathml3/mathml3-common.rng | 257 + .../schemas/mathml3/mathml3-content.rng | 1544 ++ .../schemas/mathml3/mathml3-presentation.rng | 2324 ++ .../mathml3/mathml3-strict-content.rng | 205 + .../schemas/mathml3/mathml3.rng | 23 + .../odf/OpenDocument-v1.3-dsig-schema.rng | 60 + .../odf/OpenDocument-v1.3-manifest-schema.rng | 237 + .../schemas/odf/OpenDocument-v1.3-schema.rng | 18299 ++++++++++++++++ .../schemas/odf/PROVENANCE.txt | 19 + .../schemas/ooxml/PROVENANCE.txt | 39 + appthere-conformance/schemas/ooxml/mce/mc.xsd | 75 + .../schemas/ooxml/transitional/dml-chart.xsd | 1499 ++ .../ooxml/transitional/dml-chartDrawing.xsd | 146 + .../ooxml/transitional/dml-diagram.xsd | 1085 + .../ooxml/transitional/dml-lockedCanvas.xsd | 11 + .../schemas/ooxml/transitional/dml-main.xsd | 3081 +++ .../ooxml/transitional/dml-picture.xsd | 23 + .../transitional/dml-spreadsheetDrawing.xsd | 185 + .../dml-wordprocessingDrawing.xsd | 287 + .../schemas/ooxml/transitional/pml.xsd | 1676 ++ .../shared-additionalCharacteristics.xsd | 28 + .../transitional/shared-bibliography.xsd | 144 + .../transitional/shared-commonSimpleTypes.xsd | 174 + .../shared-customXmlDataProperties.xsd | 25 + .../shared-customXmlSchemaProperties.xsd | 18 + .../shared-documentPropertiesCustom.xsd | 59 + .../shared-documentPropertiesExtended.xsd | 56 + .../shared-documentPropertiesVariantTypes.xsd | 195 + .../ooxml/transitional/shared-math.xsd | 582 + .../shared-relationshipReference.xsd | 25 + .../schemas/ooxml/transitional/sml.xsd | 4439 ++++ .../schemas/ooxml/transitional/vml-main.xsd | 570 + .../ooxml/transitional/vml-officeDrawing.xsd | 509 + .../transitional/vml-presentationDrawing.xsd | 12 + .../transitional/vml-spreadsheetDrawing.xsd | 108 + .../vml-wordprocessingDrawing.xsd | 96 + .../schemas/ooxml/transitional/wml.xsd | 3646 +++ .../schemas/ooxml/transitional/xml.xsd | 116 + .../schemas/opc/PROVENANCE.txt | 15 + .../schemas/opc/opc-contentTypes.xsd | 42 + .../schemas/opc/opc-coreProperties.xsd | 50 + .../schemas/opc/opc-digSig.xsd | 49 + .../schemas/opc/opc-relationships.xsd | 33 + loki-fonts/fonts/Gelasio-Bold.ttf | Bin 0 -> 112356 bytes loki-fonts/fonts/Gelasio-BoldItalic.ttf | Bin 0 -> 114384 bytes loki-fonts/fonts/Gelasio-Italic.ttf | Bin 0 -> 114324 bytes loki-fonts/fonts/Gelasio-Regular.ttf | Bin 0 -> 112172 bytes loki-fonts/fonts/OFL-Gelasio.txt | 93 + loki-fonts/src/lib.rs | 20 +- loki-layout/src/font.rs | 3 +- loki-layout/tests/font_substitution_suite.rs | 95 + loki-odf/tests/schema_validation.rs | 157 + loki-ooxml/tests/schema_validation.rs | 154 + 54 files changed, 42614 insertions(+), 27 deletions(-) create mode 100644 appthere-conformance/schemas/mathml3/mathml3-common.rng create mode 100644 appthere-conformance/schemas/mathml3/mathml3-content.rng create mode 100644 appthere-conformance/schemas/mathml3/mathml3-presentation.rng create mode 100644 appthere-conformance/schemas/mathml3/mathml3-strict-content.rng create mode 100644 appthere-conformance/schemas/mathml3/mathml3.rng create mode 100644 appthere-conformance/schemas/odf/OpenDocument-v1.3-dsig-schema.rng create mode 100644 appthere-conformance/schemas/odf/OpenDocument-v1.3-manifest-schema.rng create mode 100644 appthere-conformance/schemas/odf/OpenDocument-v1.3-schema.rng create mode 100644 appthere-conformance/schemas/odf/PROVENANCE.txt create mode 100644 appthere-conformance/schemas/ooxml/PROVENANCE.txt create mode 100644 appthere-conformance/schemas/ooxml/mce/mc.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/dml-chart.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/dml-chartDrawing.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/dml-diagram.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/dml-lockedCanvas.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/dml-main.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/dml-picture.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/dml-spreadsheetDrawing.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/dml-wordprocessingDrawing.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/pml.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-additionalCharacteristics.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-bibliography.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-commonSimpleTypes.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-customXmlDataProperties.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-customXmlSchemaProperties.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesCustom.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesExtended.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesVariantTypes.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-math.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/shared-relationshipReference.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/sml.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/vml-main.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/vml-officeDrawing.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/vml-presentationDrawing.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/vml-spreadsheetDrawing.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/vml-wordprocessingDrawing.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/wml.xsd create mode 100644 appthere-conformance/schemas/ooxml/transitional/xml.xsd create mode 100644 appthere-conformance/schemas/opc/PROVENANCE.txt create mode 100644 appthere-conformance/schemas/opc/opc-contentTypes.xsd create mode 100644 appthere-conformance/schemas/opc/opc-coreProperties.xsd create mode 100644 appthere-conformance/schemas/opc/opc-digSig.xsd create mode 100644 appthere-conformance/schemas/opc/opc-relationships.xsd create mode 100644 loki-fonts/fonts/Gelasio-Bold.ttf create mode 100644 loki-fonts/fonts/Gelasio-BoldItalic.ttf create mode 100644 loki-fonts/fonts/Gelasio-Italic.ttf create mode 100644 loki-fonts/fonts/Gelasio-Regular.ttf create mode 100644 loki-fonts/fonts/OFL-Gelasio.txt create mode 100644 loki-layout/tests/font_substitution_suite.rs create mode 100644 loki-odf/tests/schema_validation.rs create mode 100644 loki-ooxml/tests/schema_validation.rs diff --git a/appthere-conformance/schemas/README.md b/appthere-conformance/schemas/README.md index c5df27b1..f46e4c30 100644 --- a/appthere-conformance/schemas/README.md +++ b/appthere-conformance/schemas/README.md @@ -9,25 +9,35 @@ version-pinned** schemas, vendored here so validation is reproducible and offlin (Spec 02 **D6**). `appthere_conformance::schema::XmllintValidator` points `xmllint` at these files. -> **Status:** the validator and its plumbing are implemented and tested (against -> small in-tree schemas in the unit tests). Vendoring the *full* official schema -> sets below — and validating real DOCX/ODT exports against them — is the -> immediate next step (Spec 02 M2 completion). They are not committed yet because -> each is a large third-party drop with its own license/version that the -> maintainer should pin deliberately. +> **Status (2026-07-05): vendored and live.** Real DOCX and ODT exports are +> validated against these schemas in `loki-ooxml/tests/schema_validation.rs` +> and `loki-odf/tests/schema_validation.rs` (Spec 02 M2 acceptance: valid +> exports pass, a deliberately malformed part fails, a missing `xmllint` +> fails loudly). -## What to vendor, and where +## Layout (as vendored) -| Path | Schema | Source / version to pin | +| Path | Schema | Pinned version / source | |------|--------|--------------------------| -| `ooxml/transitional/` | ISO/IEC 29500-1 **Transitional** XSDs (`wml.xsd`, `sml.xsd`, `dml-*.xsd`, `shared-*.xsd`, …) | ECMA-376 5th ed. Transitional schema bundle. Default validation target. | -| `ooxml/strict/` | ISO/IEC 29500 **Strict** XSDs | Same bundle, Strict. Opt-in stricter pass. | -| `opc/` | OPC: `opc-contentTypes.xsd`, `opc-relationships.xsd`, `opc-coreProperties.xsd` | ECMA-376 Part 2 (OPC). Exercises `loki-opc`. | -| `odf/` | OASIS ODF **RELAX NG** (`OpenDocument-v1.3-schema.rng`) + `OpenDocument-v1.3-manifest-schema.rng` | OASIS ODF 1.3 schema. | - -Each subdirectory should also carry a `PROVENANCE.txt` recording the exact source -URL, version/edition, retrieval date, and upstream license, so the pin is -auditable. +| `ooxml/transitional/` | ISO/IEC 29500-4 **Transitional** XSDs (`wml.xsd`, `sml.xsd`, `pml.xsd`, `dml-main.xsd`, `shared-*.xsd`, `vml-*.xsd`, `xml.xsd`) | **ISO/IEC 29500-4:2016** electronic-insert bundle. Default validation target. | +| `ooxml/mce/` | Markup Compatibility & Extensibility (`mc.xsd`), imported by `wml.xsd` | Same bundle. | +| `opc/` | OPC: `opc-contentTypes.xsd`, `opc-relationships.xsd`, `opc-coreProperties.xsd`, `opc-digSig.xsd` | **ECMA-376 4th ed. Part 2**. Exercises `loki-opc` (`[Content_Types].xml`, `_rels/.rels`). | +| `odf/` | OASIS ODF **RELAX NG**: `OpenDocument-v1.3-schema.rng`, `-manifest-`, `-dsig-` | **OASIS ODF 1.3 OS**, via Maven `org.odftoolkit:odfvalidator:0.12.0`. | +| `mathml3/` | W3C MathML3 RELAX NG (referenced by ODF math content) | Same artifact. | + +Each subdirectory carries a `PROVENANCE.txt` recording the exact source, +version/edition, retrieval date, upstream license, and a per-file sha256 +manifest, so the pin is auditable. + +**Not vendored (documented tails):** + +- **Strict** ISO/IEC 29500 XSDs (`ooxml/strict/`) — the opt-in stricter pass. + Vendor from the same ISO bundle when the Strict pass is scheduled. +- The Dublin Core XSDs (`dc.xsd`, `dcterms.xsd`, `dcmitype.xsd`) that + `opc-coreProperties.xsd` imports by live URL — required before + `docProps/core.xml` can be validated offline. No in-policy source was + reachable when vendoring; see `TODO(conformance-schemas)` in + `loki-ooxml/tests/schema_validation.rs`. ## How the validator uses them @@ -37,13 +47,14 @@ use appthere_conformance::schema::{SchemaKind, SchemaValidator, XmllintValidator let v = XmllintValidator::new()?; // fails loudly if xmllint absent let report = v.validate_bytes( &exported_document_xml, - std::path::Path::new("appthere-conformance/schemas/ooxml/transitional/wml.xsd"), + // canonicalize(): libxml2 treats one file reached via two path spellings + // as two schema documents and then skips "duplicate" namespace imports. + &schemas_dir.join("ooxml/transitional/wml.xsd").canonicalize()?, SchemaKind::Xsd, )?; assert!(report.valid, "{:#?}", report.violations); // first-class located violations ``` -ODF parts use `SchemaKind::RelaxNg` against the `.rng` files. A schema *registry* -that maps each emitted part (`document.xml`, `styles.xml`, `content.xml`, -`manifest.xml`, `[Content_Types].xml`, …) to its schema file is the small wrapper -to add once the files are vendored. +ODF parts use `SchemaKind::RelaxNg` against the `.rng` files. The part→schema +mapping lives in the consumer tests (`schema_validation.rs` in `loki-ooxml` / +`loki-odf`); promote it into a shared registry here if a third consumer appears. diff --git a/appthere-conformance/schemas/mathml3/mathml3-common.rng b/appthere-conformance/schemas/mathml3/mathml3-common.rng new file mode 100644 index 00000000..f598d44e --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3-common.rng @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + block + inline + + + + + + + + + + + + linebreak + scroll + elide + truncate + scale + + + + + + + + + + + + + + + + + + + + + + + top + middle + bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \s*((-?[0-9]*([0-9]\.?|\.[0-9])[0-9]*(e[mx]|in|cm|mm|p[xtc]|%)?)|(negative)?((very){0,2}thi(n|ck)|medium)mathspace)\s* + + + diff --git a/appthere-conformance/schemas/mathml3/mathml3-content.rng b/appthere-conformance/schemas/mathml3/mathml3-content.rng new file mode 100644 index 00000000..a0a169ad --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3-content.rng @@ -0,0 +1,1544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + numeric + lexicographic + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + prefix + infix + function-model + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/mathml3/mathml3-presentation.rng b/appthere-conformance/schemas/mathml3/mathml3-presentation.rng new file mode 100644 index 00000000..2b56d562 --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3-presentation.rng @@ -0,0 +1,2324 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \s*([\+\-]?[0-9]*([0-9]\.?|\.[0-9])[0-9]*\s*((%?\s*(height|depth|width)?)|e[mx]|in|cm|mm|p[xtc]|((negative)?((very){0,2}thi(n|ck)|medium)mathspace))?)\s* + + + + + none + solid + dashed + + + + + top + bottom + center + baseline + axis + + + + + left + center + right + + + + + longdiv + actuarial + radical + box + roundedbox + circle + left + right + top + bottom + updiagonalstrike + downdiagonalstrike + verticalstrike + horizontalstrike + madruwb + + + + + + + + + + + + + + + + + \s*\S\s* + + + + + \s*((#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|[aA][qQ][uU][aA]|[bB][lL][aA][cC][kK]|[bB][lL][uU][eE]|[fF][uU][cC][hH][sS][iI][aA]|[gG][rR][aA][yY]|[gG][rR][eE][eE][nN]|[lL][iI][mM][eE]|[mM][aA][rR][oO][oO][nN]|[nN][aA][vV][yY]|[oO][lL][iI][vV][eE]|[pP][uU][rR][pP][lL][eE]|[rR][eE][dD]|[sS][iI][lL][vV][eE][rR]|[tT][eE][aA][lL]|[wW][hH][iI][tT][eE]|[yY][eE][lL][lL][oO][wW])\s* + + + + + left + center + right + decimalpoint + + + + + + + + + + + + (\s*\{\s*(left|center|right|decimalpoint)(\s+(left|center|right|decimalpoint))*\})*\s* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + prefix + infix + postfix + + + + + + + true + false + + + + + + + true + false + + + + + + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + + infinity + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + true + false + + + + + + + auto + newline + nobreak + goodbreak + badbreak + + + + + + + + + + + + before + after + duplicate + infixlinebreakstyle + + + + + + + + + + left + center + right + auto + id + + + + + + + + + + + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + indentshift + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + indentshift + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + newline + nobreak + goodbreak + badbreak + indentingnewline + + + + + + + left + center + right + auto + id + + + + + + + + + + + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + indentshift + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + indentshift + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + normal + bold + italic + bold-italic + double-struck + bold-fraktur + script + bold-script + fraktur + sans-serif + bold-sans-serif + sans-serif-italic + sans-serif-bold-italic + monospace + initial + tailed + looped + stretched + + + + + + + small + normal + big + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + thin + medium + thick + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transparent + + + + + + + + + normal + bold + italic + bold-italic + double-struck + bold-fraktur + script + bold-script + fraktur + sans-serif + bold-sans-serif + sans-serif-italic + sans-serif-bold-italic + monospace + initial + tailed + looped + stretched + + + + + + + small + normal + big + + + + + + + + ltr + rtl + + + + + + + + + + + + + normal + bold + + + + + + + normal + italic + + + + + + + + + + + + + + + + + + transparent + + + + + + + + + + + + + + + + + + + + + + + left + right + + + + + + + + + + + + + + + + + left + center + right + decimalpoint + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ltr + rtl + + + + + + + + + + + + + + + + + + + thin + medium + thick + + + + + + + left + center + right + + + + + + + left + center + right + + + + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + before + after + duplicate + + + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + left + right + center + + + + + + + + + true + false + + + + + + + + + true + false + + + + + + + left + center + right + + + + + + + + loose + medium + tight + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + + fit + + + + + + + + + + + none + updiagonalstrike + downdiagonalstrike + verticalstrike + horizontalstrike + + + + + + + + + left + center + right + + + + + + + + + + + + ltr + rtl + + + + + + + left + right + + + + + + + true + false + + + + + + + true + false + + + + + + + true + false + + + + + + + prefix + infix + postfix + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + auto + id + + + + + + + left + center + right + auto + id + indentalign + + + + + + + left + center + right + auto + id + indentalign + + + + + + + + + + + + + indentshift + + + + + + + + indentshift + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + auto + newline + nobreak + goodbreak + badbreak + + + + + + + + + + before + after + duplicate + infixlinebreakstyle + + + + + + + + + + + + + thin + medium + thick + + + + + + + w + nw + n + ne + e + se + s + sw + + + + + + + lefttop + stackedrightright + mediumstackedrightright + shortstackedrightright + righttop + left/\right + left)(right + :right=right + stackedleftleft + stackedleftlinetop + + + + + + + + + + + + + + + small + normal + big + + + + + + + + normal + bold + italic + bold-italic + double-struck + bold-fraktur + script + bold-script + fraktur + sans-serif + bold-sans-serif + sans-serif-italic + sans-serif-bold-italic + monospace + initial + tailed + looped + stretched + + + + + + + + infinity + + + + + + + + + + + + + + + + + true + false + + + + + + + + thin + medium + thick + + + + + + + + + + left + center + right + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + left + right + leftoverlap + rightoverlap + + + + + + + left + center + right + decimalpoint + + + + + + + true + false + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + left + right + center + + + + + + + + + + + + + + + + + + true + false + + + + + + + left + right + center + + + + + + + + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + left + right + center + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \s*(top|bottom|center|baseline|axis)(\s+-?[0-9]+)?\s* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + + + + + auto + + fit + + + + + + + + + auto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + false + + + + + + + true + false + + + + + + + true + false + + + + + + + left + right + leftoverlap + rightoverlap + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + bottom + center + baseline + axis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + bottom + center + baseline + axis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \s*(top|bottom|center|baseline|axis)(\s+-?[0-9]+)?\s* + + + + + + + left + center + right + decimalpoint + + + + + + + left + center + right + + + + + + + + loose + medium + tight + + + + + + + + + + + + + + + + + + + + lefttop + stackedrightright + mediumstackedrightright + shortstackedrightright + righttop + left/\right + left)(right + :right=right + stackedleftleft + stackedleftlinetop + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + w + nw + n + ne + e + se + s + sw + + + + + + + + + none + updiagonalstrike + downdiagonalstrike + verticalstrike + horizontalstrike + + + + + + + + + + + + + + + + + + + + + + + + + + w + nw + n + ne + e + se + s + sw + + + + + + + + + none + updiagonalstrike + downdiagonalstrike + verticalstrike + horizontalstrike + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/mathml3/mathml3-strict-content.rng b/appthere-conformance/schemas/mathml3/mathml3-strict-content.rng new file mode 100644 index 00000000..25e5162d --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3-strict-content.rng @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + integer + real + double + hexdouble + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + integer + rational + real + complex + complex-polar + complex-cartesian + constant + function + vector + list + set + matrix + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/mathml3/mathml3.rng b/appthere-conformance/schemas/mathml3/mathml3.rng new file mode 100644 index 00000000..f1f75fcc --- /dev/null +++ b/appthere-conformance/schemas/mathml3/mathml3.rng @@ -0,0 +1,23 @@ + + + + + Content MathML + + + Presentation MathML + + + math and semantics common to both Content and Presentation + + diff --git a/appthere-conformance/schemas/odf/OpenDocument-v1.3-dsig-schema.rng b/appthere-conformance/schemas/odf/OpenDocument-v1.3-dsig-schema.rng new file mode 100644 index 00000000..954add38 --- /dev/null +++ b/appthere-conformance/schemas/odf/OpenDocument-v1.3-dsig-schema.rng @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.3 + + + diff --git a/appthere-conformance/schemas/odf/OpenDocument-v1.3-manifest-schema.rng b/appthere-conformance/schemas/odf/OpenDocument-v1.3-manifest-schema.rng new file mode 100644 index 00000000..3ff6d39b --- /dev/null +++ b/appthere-conformance/schemas/odf/OpenDocument-v1.3-manifest-schema.rng @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + Blowfish CFB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SHA1/1K + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + edit + presentation-slide-show + read-only + + + + + + + + + + + + + + + + + + + + + PGP + + + + + PBKDF2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.3 + + + + + [^:]+:[^:]+ + + + + + + + + + + + + + + + + SHA1 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/appthere-conformance/schemas/odf/OpenDocument-v1.3-schema.rng b/appthere-conformance/schemas/odf/OpenDocument-v1.3-schema.rng new file mode 100644 index 00000000..dc77c4fc --- /dev/null +++ b/appthere-conformance/schemas/odf/OpenDocument-v1.3-schema.rng @@ -0,0 +1,18299 @@ + + + + + + + + + + + + + + + + + (([\i-[:]][\c-[:]]*)?:)?.+ + 1 + + + + + + + + + + + + + + + + + + + + + + + + \[(([\i-[:]][\c-[:]]*)?:)?.+\] + 3 + + + + + + + + + + + + + + + + + rgb + hsl + + + + + + + clockwise + counter-clockwise + + + + + + + + + + + + + + + + + + + + + discrete + linear + paced + spline + + + + + + + + + translate + scale + rotate + skewX + skewY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + forward + reverse + + + + + + + + + + + + in + out + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + An IRI-reference as defined in [RFC3987]. See ODF 1.3 Part 3 section 18.3. + + + + + + + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + ($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+$?[0-9]+ + + + + + + ($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+$?[0-9]+(:($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+$?[0-9]+)? + + + ($?([^\. ']+|'([^']|'')+'))?\.$?[0-9]+:($?([^\. ']+|'([^']|'')+'))?\.$?[0-9]+ + + + ($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+:($?([^\. ']+|'([^']|'')+'))?\.$?[A-Z]+ + + + + + + Value is a space separated list of "cellRangeAddress" patterns + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + x + y + z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + major + minor + + + + + + + + + + + + + + + + + + + + + + + + + start + end + top + bottom + + + + + + start + center + end + + + + + + + top-start + bottom-start + top-end + bottom-end + + + + + + + + + wide + high + balanced + + + + + custom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + row + column + both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rect\([ ]*((-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc)))|(auto))([ ]*,[ ]*((-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc))))|(auto)){3}[ ]*\) + + + + + #[0-9a-fA-F]{6} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + sum + + + + + + + replace + sum + + + + + + + + + + + default + on-click + with-previous + after-previous + timing-root + main-sequence + interactive-sequence + + + + + + + + + + + + + + + + + custom + entrance + exit + emphasis + motion-path + ole-action + media-call + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + discrete + linear + paced + spline + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + transparent + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + column + page + + + + + + + auto + column + page + + + + + + + + + gregorian + gengou + ROC + hanja_yoil + hanja + hijri + jewish + buddhist + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + medium + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + new + replace + + + + + + + + + + + + nohref + + + + + + + + + + + + + + + + + full + section + cut + arc + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + embed + + + + + onLoad + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + scale + scale-min + + + + + + + + scale + scale-min + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + first + last + all + media + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + table + query + command + + + + + + + + + + + + + + + + + value + formula + + + + + + + + + value + formula + none + + + + + + + + + value + none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + remove + freeze + hold + transition + auto + inherit + + + + + + + + + remove + freeze + hold + auto + default + transition + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + center + + + + + + start + end + top + bottom + + + + + + start + center + end + + + + + + + + + + + flat + 3d + + + + + + + + + fixed + language + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + always + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + i + I + + + + + + + + a + A + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + page + chapter + direction + text + + + + + + + + + + + + + + + + + + + + indefinite + + + + + + + + + never + always + whenNotActive + inherit + + + + + + + + + never + always + whenNotActive + default + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ltr + ttb + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + objectBoundingBox + + + + + + + + + + + pad + reflect + repeat + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + accepted + rejected + pending + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + start + end + left + right + center + justify + + + + + + + + + + page + frame + paragraph + char + as-char + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + float + + + + + + + + percentage + + + + + + + + currency + + + + + + + + + + + + + date + + + + + + + + time + + + + + + + + boolean + + + + + + + + string + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + middle + bottom + from-top + below + + + + + + + + + + + + + + page + page-content + frame + frame-content + paragraph + paragraph-content + char + line + baseline + text + + + + + + + + + lr-tb + rl-tb + tb-rl + tb-lr + lr + rl + tb + page + + + + + + + + + + + + + + + + + + boolean + short + int + long + double + string + datetime + base64Binary + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [A-Za-z0-9]{1,8} + + + + + + + + + + + + + + + + + + + non-primitive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + equal-integer + is-boolean + equal-boolean + equal-use-only-zero + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + no-nulls + nullable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + none + + + + + onRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + none + + + + + onRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + boolean + short + int + long + double + string + + + + + + + + + + + + + + + + + + + + + + + + + bit + boolean + tinyint + smallint + integer + bigint + float + real + double + numeric + decimal + char + varchar + longvarchar + date + time + timestmp + binary + varbinary + longvarbinary + sqlnull + other + object + distinct + struct + array + blob + clob + ref + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + primary + unique + foreign + + + + + + + + + + + cascade + restrict + set-null + no-action + set-default + + + + + + + cascade + restrict + set-null + no-action + set-default + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + parallel + perspective + + + + + + + + + + + + + + + + + + + + + + flat + phong + gouraud + draft + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + new + replace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + standard + lines + line + curve + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + flat + phong + gouraud + draft + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + parallel + perspective + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + segments + rectangle + + + + + + + + + + + + + + + + + normal + path + shape + + + + + + + path + shape + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top-left + top + top-right + left + center + right + bottom-left + bottom-right + + + + + + auto + left + right + up + down + horizontal + vertical + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + single + double + triple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + always + screen + printer + none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + rect + round + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -0.5 + 0.5 + + + + + roman + swiss + modern + decorative + script + system + + + + + fixed + variable + + + + + normal + italic + oblique + + + + + normal + small-caps + + + + + normal + bold + 100 + 200 + 300 + 400 + 500 + 600 + 700 + 800 + 900 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + + get + post + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + table + query + command + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + float + + + + + + + + + + + + percentage + + + + + + + + + + + + currency + + + + + + + + + + + + + + + + + date + + + + + + + + + + + + time + + + + + + + + + + + + boolean + + + + + + + + + + + + string + + + + + + + + + + + void + + + + + + + + void + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + horizontal + vertical + + + + + + + + linear + axial + radial + ellipsoid + square + rectangular + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + + + + + horizontal + horizontal-on-odd + horizontal-on-even + + + + + + + + + + + + + + + + + + + + + + + + + + + + avoid-overlap + center + top + top-right + right + bottom-right + bottom + bottom-left + left + top-left + inside + outside + near-origin + + + + + + + + [A-Za-z]{1,8} + + + + + -?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc)|(px)) + + + + + continuous + skip-white-space + + + + + none + solid + dotted + dash + long-dash + dot-dash + dot-dot-dash + wave + + + + + none + single + double + + + + + auto + normal + bold + thin + medium + thick + + + + + + + + + + selection + selection-indices + + + + + + + + + + + + + + + + table + query + sql + sql-pass-through + value-list + table-fields + + + + + + + + + + + To avoid inclusion of the complete MathML schema, anything is allowed within a math:math top-level element + + + + + + + + + + + + + + + + + + + + + [^:]+:[^:]+ + + + + + none + current + parent + + + + + 0.0 + + + + + + + + ([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc)|(px)) + + + + + ([0-9]+(\.[0-9]*)?|\.[0-9]+)(px) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + short + long + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + into-default-style-data-style + into-english-number + keep-text + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1.3 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + + + + + + + simple + + + + + + + replace + + + + + onLoad + + + + + + + + + + + + + + + + + + + new + replace + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + float + + + + + + date + + + + + + time + + + + + + boolean + + + + + + string + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text:page-count + text:paragraph-count + text:word-count + text:character-count + text:table-count + text:image-count + text:object-count + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text:reference-ref + text:bookmark-ref + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + value + unit + gap + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text:identifier + text:address + text:annote + text:author + text:booktitle + text:chapter + text:edition + text:editor + text:howpublished + text:institution + text:journal + text:month + text:note + text:number + text:organizations + text:pages + text:publisher + text:school + text:series + text:title + text:report-type + text:volume + text:year + text:url + text:custom1 + text:custom2 + text:custom3 + text:custom4 + text:custom5 + text:isbn + text:issn + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -?([0-9]+(\.[0-9]*)?|\.[0-9]+)% + + + + + \([ ]*-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc))([ ]+-?([0-9]+(\.[0-9]*)?|\.[0-9]+)((cm)|(mm)|(in)|(pt)|(pc))){2}[ ]*\) + + + + + -?[0-9]+,-?[0-9]+([ ]+-?[0-9]+,-?[0-9]+)* + + + + + + + + ([0-9]*[1-9][0-9]*(\.[0-9]*)?|0+\.[0-9]*[1-9][0-9]*|\.[0-9]*[1-9][0-9]*)((cm)|(mm)|(in)|(pt)|(pc)|(px)) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + title + outline + subtitle + text + graphic + object + chart + table + orgchart + page + notes + handout + header + footer + date-time + page-number + + + + + + + + + + fixed + current-date + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + previous-page + next-page + first-page + last-page + hide + stop + execute + show + verb + fade-out + sound + last-visited-page + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + embed + + + + + onRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + enabled + disabled + + + + + + + enabled + disabled + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + new + replace + + + + + + + + + + + + + + + + + + + + + none + from-left + from-top + from-right + from-bottom + from-center + from-upper-left + from-upper-right + from-lower-left + from-lower-right + to-left + to-top + to-right + to-bottom + to-upper-left + to-upper-right + to-lower-right + to-lower-left + path + spiral-inward-left + spiral-inward-right + spiral-outward-left + spiral-outward-right + vertical + horizontal + to-center + clockwise + counter-clockwise + + + + + none + fade + move + stripes + open + close + dissolve + wavyline + random + lines + laser + appear + hide + move-short + checkerboard + rotate + stretch + + + + + slow + medium + fast + + + + + + + + + + [0-9]+\* + + + + + row + column + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + [A-Za-z0-9]{1,8} + + + + + + + + + + + + none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -?([0-9]?[0-9](\.[0-9]*)?|100(\.0*)?|\.[0-9]+)% + + + + + + + + + + + + unchecked + checked + unknown + + + + + + + + + + + + + + + + + + + + + + + no-repeat + repeat + stretch + + + + + + + left + center + right + top + bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + + + automatic + + + + named-symbol + + + + square + diamond + arrow-down + arrow-up + arrow-right + arrow-left + bow-tie + hourglass + circle + star + x + plus + asterisk + horizontal-bar + vertical-bar + + + + + + image + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + cubic-spline + b-spline + step-start + step-end + step-center-x + step-center-y + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + cuboid + cylinder + cone + pyramid + + + + + + + + + + + + + + + + + use-zero + leave-gap + ignore + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + side-by-side + stagger-even + stagger-odd + + + + + + + + + none + value + percentage + value-and-percentage + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + variance + standard-deviation + percentage + error-margin + constant + standard-error + cell-range + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + columns + rows + + + + + + + none + linear + logarithmic + moving-average + exponential + power + polynomial + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + prior + central + averaged-abscissa + + + + + + + + start + end + + + + + + + + near-axis + near-axis-other-side + outside-start + outside-end + + + + + + + at-labels + at-axis + at-labels-and-axis + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + solid + dotted + dashed + dot-dashed + + + + + + + + + + + + + + + top + middle + bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + manual + automatic + semi-automatic + + + + + + + none + fade-from-left + fade-from-top + fade-from-right + fade-from-bottom + fade-from-upperleft + fade-from-upperright + fade-from-lowerleft + fade-from-lowerright + move-from-left + move-from-top + move-from-right + move-from-bottom + move-from-upperleft + move-from-upperright + move-from-lowerleft + move-from-lowerright + uncover-to-left + uncover-to-top + uncover-to-right + uncover-to-bottom + uncover-to-upperleft + uncover-to-upperright + uncover-to-lowerleft + uncover-to-lowerright + fade-to-center + fade-from-center + vertical-stripes + horizontal-stripes + clockwise + counterclockwise + open-vertical + open-horizontal + close-vertical + close-horizontal + wavyline-from-left + wavyline-from-top + wavyline-from-right + wavyline-from-bottom + spiralin-left + spiralin-right + spiralout-left + spiralout-right + roll-from-top + roll-from-left + roll-from-right + roll-from-bottom + stretch-from-left + stretch-from-top + stretch-from-right + stretch-from-bottom + vertical-lines + horizontal-lines + dissolve + random + vertical-checkerboard + horizontal-checkerboard + interlocking-horizontal-left + interlocking-horizontal-right + interlocking-vertical-top + interlocking-vertical-bottom + fly-away + open + close + melt + + + + + + + + + + + + + + + + + + + + + + forward + reverse + + + + + + + + + + + + + + + + + visible + hidden + + + + + + + full + border + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + word + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + normal + ultra-condensed + extra-condensed + condensed + semi-condensed + semi-expanded + expanded + extra-expanded + ultra-expanded + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + + + + + + + + + + + + + + + + + + + + none + solid + bitmap + gradient + hatch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + no-repeat + repeat + stretch + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top-left + top + top-right + left + center + right + bottom-left + bottom + bottom-right + + + + + + + + + horizontal + vertical + + + + + + + + + + + + + + + + + + nonzero + evenodd + + + + + + + + + + + + + + + + none + dash + solid + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 1 + + + + + + + + + miter + round + bevel + middle + none + + + + + + + butt + square + round + + + + + + + + + + + + none + scroll + alternate + slide + + + + + + + left + right + up + down + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + middle + bottom + justify + + + + + + + left + center + right + justify + + + + + + + no-wrap + wrap + + + + + + + + + + + + greyscale + mono + watermark + standard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + visible + hidden + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + below + above + + + + + + + + + + + + automatic + left-outside + inside + right-outside + + + + + + + automatic + above + below + center + + + + + + + automatic + mm + cm + m + km + pt + pc + inch + ft + mi + + + + + + + + + + + + + + + + + straight-line + angled-line + angled-connector-line + + + + + + + fixed + free + + + + + + + + + + + + + + + + + horizontal + vertical + auto + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + correct + attractive + + + + + + + + + + + + + + + + + enabled + disabled + + + + + + + + + + + + + + + + + + + + + + standard + double-sided + + + + + + + object + flat + sphere + + + + + + + normal + inverse + + + + + + + object + parallel + sphere + + + + + + + object + parallel + sphere + + + + + + + luminance + intensity + color + + + + + + + enabled + disabled + + + + + + + replace + modulate + blend + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + visible + hidden + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + + + + content + position + size + + + + + + + + + + left + center + right + from-left + inside + outside + from-inside + + + + + + + + + + + + page + page-content + page-start-margin + page-end-margin + frame + frame-content + frame-start-margin + frame-end-margin + paragraph + paragraph-content + paragraph-start-margin + paragraph-end-margin + char + + + + + + + + + + + + + + + + + none + left + right + parallel + dynamic + run-through + biggest + + + + + + + + + + + + no-limit + + + + + + + + + + + + + full + outside + + + + + + + foreground + background + + + + + + + + + + + + clip + auto-create-new-frame + + + + + + + none + vertical + + + vertical + + + + + vertical + + + + + + + + auto + + + + + + + + iterative + once-concurrent + once-successive + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + content + thumbnail + icon + print-view + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + listtab + space + nothing + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + label-width-and-position + label-alignment + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + left + right + mirrored + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + default + + + + + + + + portrait + landscape + + + + + + + + + + + + + + + + + + + + + + headers + grid + annotations + objects + charts + drawings + formulas + zero-values + + + + + + + + + ttb + ltr + + + + + + + + continue + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + horizontal + vertical + both + none + + + + + + + + + + + + + none + line + both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + normal + + + + + + + + + + + + + + + + + + + + + + + + + start + center + justify + + + + + + + + + + + + auto + always + + + + + + + + + + + + + + + + + + + + + + auto + page + + + + + + + no-limit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + ideograph-alpha + + + + + + + simple + hanging + + + + + + + normal + strict + + + + + + + top + middle + bottom + auto + baseline + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + above + below + + + + + + + left + center + right + distribute-letter + distribute-space + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text + + + + + + + + paragraph + + + + + + + + + + + section + + + + + + + + ruby + + + + + + + + table + + + + + + + + table-column + + + + + + + + table-row + + + + + + + + table-cell + + + + + + + + + + + + + + + graphic + presentation + + + + + + + + + + + + + + + drawing-page + + + + + + + + chart + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + + + + + + char + + + + + + + + + + + + + + + + + + + + + + + font-color + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + top + middle + bottom + automatic + + + + + + + fix + value-type + + + + + + + + auto + 0 + 0deg + 0rad + 0grad + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + no-wrap + wrap + + + + + + + + none + bottom + top + center + + + + + + + none + hidden-and-protected + + + + protected + formula-hidden + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + center + right + margins + + + + + + + + + + + + + + + + + + + + collapsing + separating + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + always + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + lowercase + uppercase + capitalize + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + font-color + + + + + + + + + + + + + + + + + + + + super + sub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + latin + asian + complex + ignore + + + + + + + + normal + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + embossed + engraved + + + + + + + + + + + + + + + + + + + + + + + + + + + font-color + + + + + + + + + + + + + + + + + + + + + + + font-color + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + letters + lines + + + + + + + + + + + + + + + + + none + + + none + accent + dot + circle + disc + + + above + below + + + + + + + + + + + + + + + + + + + fixed + line-height + + + + + + + + + + + + + + + + + + + + + true + + + none + + + + condition + + + none + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + records + current + page + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + average + count + countnums + max + min + product + stdev + stdevp + sum + var + varp + + + + + + + + + + + + + none + row + column + both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + from-top + from-bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + row + column + data + hidden + + + + + page + + + + + + + + + + + + + + + auto + average + count + countnums + max + min + product + stdev + stdevp + sum + var + varp + + + + + + + + + + + + + + + + + + + + + + + + + named + + + + + + + + previous + next + + + + + + none + member-difference + member-percentage + member-percentage-difference + running-total + row-percentage + column-percentage + total-percentage + index + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + + + + + + auto + + + + + + + + + + auto + + + + + + auto + + + + + + + + + + + + + seconds + minutes + hours + days + months + quarters + years + + + + + + + + + + + + + + + + + tabular-layout + outline-subtotals-top + outline-subtotals-bottom + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + data + + + + + + + + none + manual + name + + + + + + ascending + descending + + + + + + + + + + + + + + auto + average + count + countnums + max + min + product + stdev + stdevp + sum + var + varp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + row + column + both + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + column + row + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + row + column + table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + stop + warning + information + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + self + cell-range + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text + number + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + from-another-table + to-another-table + from-same-table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + row + column + table + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + enable + disable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + column + row + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + + + + print-range + filter + repeat-row + repeat-column + + + + + + + + + + + + date + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + trace-dependents + remove-dependents + trace-precedents + remove-precedents + trace-errors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + alpha-numeric + integer + double + + + + + + + + + + + + + + + + + + + + text + number + automatic + + + + + + + + ascending + descending + + + + + + + + + + + + + + + + + text + number + automatic + + + + + + + + ascending + descending + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + average + count + countnums + max + min + product + stdev + stdevp + sum + var + varp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + copy-all + copy-results-only + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + none + unsorted + sort-ascending + + + + + + + + visible + collapse + filter + + + + + + + + + + + + + + + + + + + _self + _blank + _parent + _top + + + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + onRequest + + + + + + + + + + + new + replace + + + + + + + + + + + + + + + + + + + + + + + + + + simple + + + + + + + + + + + + + + + + + + + + + + + + + 1 + 2 + 3 + separator + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + article + book + booklet + conference + custom1 + custom2 + custom3 + custom4 + custom5 + email + inbook + incollection + inproceedings + journal + manual + mastersthesis + misc + phdthesis + proceedings + techreport + unpublished + www + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + number-no-superior + number-all-superior + number + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + name + number + number-and-name + plain-number-and-name + plain-number + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + full + path + name + name-and-extension + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text + category-and-value + caption + + + + + + + + + + + + + + + + + + + + + + + + + + + address + annote + author + bibliography-type + booktitle + chapter + custom1 + custom2 + custom3 + custom4 + custom5 + edition + editor + howpublished + identifier + institution + isbn + issn + journal + month + note + number + organizations + pages + publisher + report-type + school + series + title + url + volume + year + + + + + + + + + + + + + + + + + + + + name + number + number-and-name + plain-number + plain-number-and-name + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + right + + + + left + + + + + + + + + + + + + + + + + + + + + + + + + + + document + chapter + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + left + right + inner + outer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + footnote + endnote + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + document + chapter + page + + + + + + + text + page + section + document + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + previous + next + + + + + + + + + + + + + + + + + + + + + + + + previous + current + next + + + + + + + + + + + + + + + + + text + table + text-box + image + object + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + none + + + + + condition + + + + + + + + + + + + + + + + + + + simple + + + + + + + embed + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + category-and-value + caption + value + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + address + annote + author + bibliography-type + booktitle + chapter + custom1 + custom2 + custom3 + custom4 + custom5 + edition + editor + howpublished + identifier + institution + isbn + issn + journal + month + note + number + organizations + pages + publisher + report-type + school + series + title + url + volume + year + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + document + chapter + + + + + + + + + + + + + + + full + path + name + name-and-extension + area + title + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [A-Za-z][A-Za-z0-9._\-]* + + + + + + + + + + + + + + submit + reset + push + url + + + + + float + time + date + percentage + currency + boolean + string + + + + + + + + \([ ]*-?([0-9]+(\.[0-9]*)?|\.[0-9]+)([ ]+-?([0-9]+(\.[0-9]*)?|\.[0-9]+)){2}[ ]*\) + + + + + top + center + bottom + + + + + + + + + + + + + + + + + + + + + + ([0-9]?[0-9](\.[0-9]*)?|100(\.0*)?|\.[0-9]+)% + + + + + 0 + 1 + + + diff --git a/appthere-conformance/schemas/odf/PROVENANCE.txt b/appthere-conformance/schemas/odf/PROVENANCE.txt new file mode 100644 index 00000000..d2afb1f9 --- /dev/null +++ b/appthere-conformance/schemas/odf/PROVENANCE.txt @@ -0,0 +1,19 @@ +OASIS OpenDocument Format v1.3 OS RELAX NG schemas (schema, manifest, dsig) +and the W3C MathML3 RELAX NG set (referenced by ODF math content). + +Source: extracted 2026-07-05 from Maven Central artifact +org.odftoolkit:odfvalidator:0.12.0 (odfvalidator-0.12.0-classes.jar, +sha256 9869ba7e15f8271ea12e54b5d611519da03616095459e6a820d7b31ff505def1), +paths schema/odf1.3/*.rng and schema/mathml3/*.rng. +Upstream origin: https://docs.oasis-open.org/office/OpenDocument/v1.3/os/schemas/ +(OASIS copyright, redistributable with notice) and https://www.w3.org/Math/RelaxNG/. + +sha256 manifest: +1760eab7fba2394b6b9d2c77b4ed2187ed01ff8029f8538ba7bff3b834866573 odf/OpenDocument-v1.3-dsig-schema.rng +8aee71f03484be112af972d622cc9031280c007b0a454ae8815e8e333c9bdd17 odf/OpenDocument-v1.3-manifest-schema.rng +40bad03efdbb02825230d357da0aa6ac679934c5bf56c6281752c0c24d58e4e6 odf/OpenDocument-v1.3-schema.rng +7d197e1dafda7ea85dd3bd9703f34a957002189b328771954177c225ea39a843 mathml3/mathml3-common.rng +fc751250f9c0e5ebfe60fcce8210a09a24dff30034d52f258d28a1ab8c939d0c mathml3/mathml3-content.rng +025b24864a7ed2f4902e06412e18870d044ede6868d5dcd9086d5b11a32757a5 mathml3/mathml3-presentation.rng +4d6bf5094f37234013c3c0bd41913fdacbdda7f0caf4ffffb5445e6937a2d2f8 mathml3/mathml3-strict-content.rng +2b5045425b926e52685a9fab5c2835a2f792b17f06fb59a1e2e1328054129570 mathml3/mathml3.rng diff --git a/appthere-conformance/schemas/ooxml/PROVENANCE.txt b/appthere-conformance/schemas/ooxml/PROVENANCE.txt new file mode 100644 index 00000000..cbefc4e1 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/PROVENANCE.txt @@ -0,0 +1,39 @@ +ISO/IEC 29500-4:2016 (Office Open XML, Transitional Migration Features) XML Schemas +plus the OSP-covered Markup Compatibility schema (mce/mc.xsd). + +Source: the ISO-IEC29500-4_2016 electronic-insert schema bundle (freely +distributable ISO/ECMA standard attachments), vendored 2026-07-05 from the +agent environment's document tooling at +/mnt/skills/public/docx/scripts/office/schemas/{ISO-IEC29500-4_2016,mce}. +Upstream origin: https://standards.iso.org/ittf/PubliclyAvailableStandards/ +(ISO/IEC 29500-4:2016 electronic inserts). + +sha256 manifest: +4de4390a26e7e44e682ed98b39872cce2620f807f5b0de421567a43f74526388 ooxml/transitional/dml-chart.xsd +3fd0586f2637b98bb9886f0e0b67d89e1cc987c2d158cc7deb5f5b9890ced412 ooxml/transitional/dml-chartDrawing.xsd +809f77f658f71e10f5cfbf99e25c895a23a4e62c81210b59a2fe8c7fb24849d2 ooxml/transitional/dml-diagram.xsd +5cb76dabd8b97d1e9308a1700b90c20139be4d50792d21a7f09789f5cccd6026 ooxml/transitional/dml-lockedCanvas.xsd +6978ba7e889070b0c3cb5b546b23e5a6c3516134afc53b87a21f482ca33f3858 ooxml/transitional/dml-main.xsd +5d389d42befbebd91945d620242347caecd3367f9a3a7cf8d97949507ae1f53c ooxml/transitional/dml-picture.xsd +b4532b6d258832953fbb3ee4c711f4fe25d3faf46a10644b2505f17010d01e88 ooxml/transitional/dml-spreadsheetDrawing.xsd +2dbfd4719505bf39b568005994a433997cbeac59cebc9d4867d564c94e9ba03a ooxml/transitional/dml-wordprocessingDrawing.xsd +39c6396b7b1f096885c3ceba5dab30e3ee14291c45a1d6fa6038ec4db69d88d0 ooxml/transitional/pml.xsd +3c6709101c6aaa82888df5d8795c33f9e857196790eb320d9194e64be2b6bdd8 ooxml/transitional/shared-additionalCharacteristics.xsd +0b364451dc36a48dd6dae0f3b6ada05fd9b71e5208211f8ee5537d7e51a587e2 ooxml/transitional/shared-bibliography.xsd +d90722af8f1439ae6b82d57858ee0b0720e320eb309cf30371666338aa66d106 ooxml/transitional/shared-commonSimpleTypes.xsd +0ef4bb354ff44b923564c4ddbdda5987919d220225129ec94614a618ceafc281 ooxml/transitional/shared-customXmlDataProperties.xsd +0d103b99a4a8652f8871552a69d42d2a3760ac6a5e3ef02d979c4273257ff6a4 ooxml/transitional/shared-customXmlSchemaProperties.xsd +9c085407751b9061c1f996f6c39ce58451be22a8d334f09175f0e89e42736285 ooxml/transitional/shared-documentPropertiesCustom.xsd +bc92e36ccd233722d4c5869bec71ddc7b12e2df56059942cce5a39065cc9c368 ooxml/transitional/shared-documentPropertiesExtended.xsd +7b5b7413e2c895b1e148e82e292a117d53c7ec65b0696c992edca57b61b4a74b ooxml/transitional/shared-documentPropertiesVariantTypes.xsd +f812dffca0bb66db6e2ec2e01dd19258636b2a5a3a0bb949a2e7295665e0ce61 ooxml/transitional/shared-math.xsd +12264f3c03d738311cd9237d212f1c07479e70f0cbe1ae725d29b36539aef637 ooxml/transitional/shared-relationshipReference.xsd +495debc8fa967b77ed37799747b049832f2c95b2ecdb9f19ddcd68f8c9f96ab9 ooxml/transitional/sml.xsd +335e9ecd40bb594ec4b916ac8c7b9eead4e16fe1b79f9d4105fdb1c43b66bb1c ooxml/transitional/vml-main.xsd +585bedc1313b40888dcc544cb74cd939a105ee674f3b1d3aa1cc6d34f70ff155 ooxml/transitional/vml-officeDrawing.xsd +133c9f64a5c5d573b78d0a474122b22506d8eadb5e063f67cdbbb8fa2f161d0e ooxml/transitional/vml-presentationDrawing.xsd +6bdeb169c3717eb01108853bd9fc5a3750fb1fa5b82abbdd854d49855a40f519 ooxml/transitional/vml-spreadsheetDrawing.xsd +475dcae1e7d1ea46232db6f8481040c15e53a52a3c256831d3df204212b0e831 ooxml/transitional/vml-wordprocessingDrawing.xsd +c2dd9f61f892deae6acd8d20771ea79b12018af25f3bf8d06639c8542d218cfd ooxml/transitional/wml.xsd +70a3c67959f9ad2333016328716101bf8b476e682f29830cc9ee8da63dca7cc2 ooxml/transitional/xml.xsd +3a37e461ecf5a8670fdec34029703401f8728ab9c96ec1739a6ae58d55212413 ooxml/mce/mc.xsd diff --git a/appthere-conformance/schemas/ooxml/mce/mc.xsd b/appthere-conformance/schemas/ooxml/mce/mc.xsd new file mode 100644 index 00000000..ef725457 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-chart.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-chart.xsd new file mode 100644 index 00000000..6454ef9a --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-chartDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-chartDrawing.xsd new file mode 100644 index 00000000..afa4f463 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-diagram.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-diagram.xsd new file mode 100644 index 00000000..64e66b8a --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-lockedCanvas.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-lockedCanvas.xsd new file mode 100644 index 00000000..687eea82 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-main.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-main.xsd new file mode 100644 index 00000000..6ac81b06 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-picture.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-picture.xsd new file mode 100644 index 00000000..1dbf0514 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-spreadsheetDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..f1af17db --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/dml-wordprocessingDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/dml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..0a185ab6 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/pml.xsd b/appthere-conformance/schemas/ooxml/transitional/pml.xsd new file mode 100644 index 00000000..14ef4888 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-additionalCharacteristics.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-additionalCharacteristics.xsd new file mode 100644 index 00000000..c20f3bf1 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-bibliography.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-bibliography.xsd new file mode 100644 index 00000000..ac602522 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-commonSimpleTypes.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-commonSimpleTypes.xsd new file mode 100644 index 00000000..424b8ba8 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-customXmlDataProperties.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-customXmlDataProperties.xsd new file mode 100644 index 00000000..2bddce29 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-customXmlSchemaProperties.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-customXmlSchemaProperties.xsd new file mode 100644 index 00000000..8a8c18ba --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesCustom.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesCustom.xsd new file mode 100644 index 00000000..5c42706a --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesExtended.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesExtended.xsd new file mode 100644 index 00000000..853c341c --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesVariantTypes.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 00000000..da835ee8 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-math.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-math.xsd new file mode 100644 index 00000000..87ad2658 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/shared-relationshipReference.xsd b/appthere-conformance/schemas/ooxml/transitional/shared-relationshipReference.xsd new file mode 100644 index 00000000..9e86f1b2 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/sml.xsd b/appthere-conformance/schemas/ooxml/transitional/sml.xsd new file mode 100644 index 00000000..d0be42e7 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-main.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-main.xsd new file mode 100644 index 00000000..8821dd18 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-officeDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-officeDrawing.xsd new file mode 100644 index 00000000..ca2575c7 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-presentationDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-presentationDrawing.xsd new file mode 100644 index 00000000..dd079e60 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-spreadsheetDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-spreadsheetDrawing.xsd new file mode 100644 index 00000000..3dd6cf62 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/vml-wordprocessingDrawing.xsd b/appthere-conformance/schemas/ooxml/transitional/vml-wordprocessingDrawing.xsd new file mode 100644 index 00000000..f1041e34 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/wml.xsd b/appthere-conformance/schemas/ooxml/transitional/wml.xsd new file mode 100644 index 00000000..9c5b7a63 --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/ooxml/transitional/xml.xsd b/appthere-conformance/schemas/ooxml/transitional/xml.xsd new file mode 100644 index 00000000..0f13678d --- /dev/null +++ b/appthere-conformance/schemas/ooxml/transitional/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/appthere-conformance/schemas/opc/PROVENANCE.txt b/appthere-conformance/schemas/opc/PROVENANCE.txt new file mode 100644 index 00000000..71733593 --- /dev/null +++ b/appthere-conformance/schemas/opc/PROVENANCE.txt @@ -0,0 +1,15 @@ +ECMA-376 4th edition, Part 2: Open Packaging Conventions XML Schemas. + +Source: the ecma/fouth-edition OPC schema set of the same bundle as +../ooxml (vendored 2026-07-05). Upstream origin: +https://ecma-international.org/publications-and-standards/standards/ecma-376/ + +NOTE: opc-coreProperties.xsd imports the Dublin Core XSDs by live URL +(dublincore.org); those are not yet vendored, so offline validation of +docProps/core.xml is deferred — see loki-ooxml/tests/schema_validation.rs. + +sha256 manifest: +49ec1c03440a9b5590e69acdf92b200fe133697de0b05f43a129fb94d1885026 opc/opc-contentTypes.xsd +69688ea15f82a870053b9a60c79359063dbcfeec9cdabafabad51a53934bc35b opc/opc-coreProperties.xsd +ee7e7e1b9a1340536e25e87c8650a2b90a43b0e67d02d6746cd430048b3c1439 opc/opc-digSig.xsd +607ffb6879cf5d5d765bfdfb2548f3f427ea964ddee6d08a15f0dc781749866d opc/opc-relationships.xsd diff --git a/appthere-conformance/schemas/opc/opc-contentTypes.xsd b/appthere-conformance/schemas/opc/opc-contentTypes.xsd new file mode 100644 index 00000000..a6de9d27 --- /dev/null +++ b/appthere-conformance/schemas/opc/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/opc/opc-coreProperties.xsd b/appthere-conformance/schemas/opc/opc-coreProperties.xsd new file mode 100644 index 00000000..10e978b6 --- /dev/null +++ b/appthere-conformance/schemas/opc/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/opc/opc-digSig.xsd b/appthere-conformance/schemas/opc/opc-digSig.xsd new file mode 100644 index 00000000..4248bf7a --- /dev/null +++ b/appthere-conformance/schemas/opc/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/appthere-conformance/schemas/opc/opc-relationships.xsd b/appthere-conformance/schemas/opc/opc-relationships.xsd new file mode 100644 index 00000000..56497467 --- /dev/null +++ b/appthere-conformance/schemas/opc/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loki-fonts/fonts/Gelasio-Bold.ttf b/loki-fonts/fonts/Gelasio-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0f6307f8a327d64dc158dc32f1aa0e4135221238 GIT binary patch literal 112356 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

bi3jF!zw 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 literal 0 HcmV?d00001 diff --git a/loki-fonts/fonts/Gelasio-Italic.ttf b/loki-fonts/fonts/Gelasio-Italic.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9c9c732dc1159cc272729dbfb8162f546af6d8f5 GIT binary patch literal 114324 zcmd442Y3@l6EM8xPLi!+b-7!zY|91r-iAjjBAoSi#Xd!_#5<&v$ zJ*0>9`X&L=>zhgvu=LOFNkS$hUy}Fxp6ADmPnz4^+nJr6w!3G95kdiIF`{TnRe42a ziY;>t!s|vML~E-0)(;5Ye%lg+H{OX*_{pjPjiohf(>_M{INT3NsvnS?Hu~tBw;_b* zKr3w{Ck~%-*XjjdAf$);idkcZPoDyKEwtAHSH+kKb4SlUId(Kc_Pz+0j_7C~KC0lo zh=XwNGC)=bAhe&UUV-*9;CocZ#2It;y?)z8`2IFDvwFhhk;9Ky4%v%P@W;^HxQWB( zOcBcQAh_QQ<*k#3Pi#*;)4vVj_LB&a;3<=*&loX!bSlDQ&LR}OYRa_sDIK!4%c1`J zP+pE0?U<-$J^S;BVF3j{E2shx2z@%agWc!XuBW?}E5rPTB7$T9(m$vtpU_o~kpD|v zPhWmo8Ad-x+uZl~MbNRfg$#5CYHdU!f6|o#0(=*QQ<&XBV!H66>kG7-UD0^*A{s~f zUMY&u0fZL8_i-NFfQB-JmLT=bcewiyORZLfx3K&4U2zRutcX!^L~M24@NYo=q-&!G z#C)XpfNUYUjeoYI>%>0;CzAQUB!LK@uLbf1w9JF|TZ3c_`1d>%ApS!T9!9b|niTA$DCyMuHL>P$cQS=|+O$jHwj!cSQk%?XZ3god@?#su!zVHP_Fgh)>yDs`yxh}HnUw}<9 z+jAd9h;IXb{w;)}xU1_5=Ul&eK-`Dw{|*QXQ03nRah2-@;YZZ*cR;2__XFJFzOv8% z9P-hOtLur4NW22BdGeRgxIY8&xa$QE2w$U`Yk~OqRrs?A3x5JQ7v+gbuK$Wjs1K7B z_c(teYy%$tLy(1`C=Upe&~RU17!_fpcjD_n6G8~>e93{JPX>c73pa6&fXomaSguPIw!6o>Dkc(RX z0Mk%!4~|r$DQ<`by#k+I&F~%5R*p}GHW}`E!0F|FXy{ciSU8P_{53egm8;xk4xlEX z4h{WZfWD5(|4{#c>vq8n^e)u*3b=l`@)sjF{ z5K(s=UFW*|XMi`MQn#!nF*#%M!N#2F0{ktwGXU%#iA8Os0Nsh-L#v1rO%$WiMA>`j zOTpplLpGz&=^1pKYi;Yi7f|wu{-OzpypTruf

Sp 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 C3J`7p literal 0 HcmV?d00001 diff --git a/loki-fonts/fonts/Gelasio-Regular.ttf b/loki-fonts/fonts/Gelasio-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..36d51419a5a7cbbc3d4f22b81a798062b5b7ca74 GIT binary patch literal 112172 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 n9{eND*ZVW. +//! . The bundled Gelasio faces (added under +//! Spec 02 B-10; license in `fonts/OFL-Gelasio.txt`, no Reserved Font Name) +//! were reconstructed from the `@fontsource/gelasio` npm distribution by +//! merging its latin + latin-ext + vietnamese subsets with fonttools — the +//! full upstream coverage for this face. //! //! # Usage //! @@ -50,10 +55,10 @@ const ATKINSON_VF: &[u8] = include_bytes!("../fonts/AtkinsonHyperlegibleNext-VF. /// Raw bytes of every bundled UI/fallback face, for **synchronous** registration /// into the renderer's Parley `FontContext` at launch. /// -/// Includes the Atkinson Hyperlegible Next UI variable font followed by the five +/// Includes the Atkinson Hyperlegible Next UI variable font followed by the six /// metric-compatible fallback families (see [`fallback_font_blobs`]). Registering /// these at startup makes the family names ("Atkinson Hyperlegible Next", -/// "Carlito", "Caladea", "Arimo", "Cousine", "Tinos") resolve immediately on +/// "Carlito", "Caladea", "Arimo", "Cousine", "Tinos", "Gelasio") resolve immediately on /// every platform, without relying on the asynchronous `@font-face` `data:` URI /// fetch (which is unreliable on Android). pub fn ui_font_blobs() -> Vec> { @@ -64,8 +69,8 @@ pub fn ui_font_blobs() -> Vec> { } /// Raw bytes of every bundled metric-compatible fallback face (Carlito, Caladea, -/// Arimo, Cousine, Tinos), for direct registration into the document layout -/// engine's font collection. +/// Arimo, Cousine, Tinos, Gelasio), for direct registration into the document +/// layout engine's font collection. /// /// Available on **all** platforms. The layout engine registers these lazily, only /// when a substitute family (e.g. Carlito for Calibri) is requested but not found @@ -100,6 +105,11 @@ pub fn fallback_font_blobs() -> &'static [&'static [u8]] { include_bytes!("../fonts/Carlito-Bold.ttf"), include_bytes!("../fonts/Carlito-Italic.ttf"), include_bytes!("../fonts/Carlito-BoldItalic.ttf"), + // Gelasio — metric-compatible Georgia (Spec 02 B-10) + include_bytes!("../fonts/Gelasio-Regular.ttf"), + include_bytes!("../fonts/Gelasio-Bold.ttf"), + include_bytes!("../fonts/Gelasio-Italic.ttf"), + include_bytes!("../fonts/Gelasio-BoldItalic.ttf"), ]; FACES } diff --git a/loki-layout/src/font.rs b/loki-layout/src/font.rs index 2c598157..db3dca91 100644 --- a/loki-layout/src/font.rs +++ b/loki-layout/src/font.rs @@ -163,6 +163,7 @@ impl FontResources { "times new roman" => Some("Tinos"), "calibri" => Some("Carlito"), "cambria" => Some("Caladea"), + "georgia" => Some("Gelasio"), _ => None, }; @@ -252,7 +253,7 @@ mod tests { #[test] fn substituted_family_is_actually_available() { let mut r = FontResources::new(); - for requested in ["Calibri", "Arial", "Times New Roman", "Cambria"] { + for requested in ["Calibri", "Arial", "Times New Roman", "Cambria", "Georgia"] { let resolved = r.resolve_font_name(requested); assert!( r.font_cx.collection.family_id(resolved.as_str()).is_some(), diff --git a/loki-layout/tests/font_substitution_suite.rs b/loki-layout/tests/font_substitution_suite.rs new file mode 100644 index 00000000..ac37fa4e --- /dev/null +++ b/loki-layout/tests/font_substitution_suite.rs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Font-substitution suite (Spec 02 §7.3 / D4). +//! +//! Fidelity fixtures reference the metric-compatible font names directly, so +//! rendering diffs never mix with substitution behaviour. This suite is the +//! *other* half: it references the **original proprietary names** and asserts +//! the substitution engine (`FontResources::resolve_font_name`) maps each to +//! its bundled metric-compatible equivalent, records the substitution (which +//! is what drives the editor's font-substitution warning), and actually has +//! the substitute face available for layout. + +use loki_layout::FontResources; + +/// Every proprietary family with a bundled metric-compatible substitute. +const SUBSTITUTION_TABLE: &[(&str, &str)] = &[ + ("Calibri", "Carlito"), + ("Cambria", "Caladea"), + ("Arial", "Arimo"), + ("Courier New", "Cousine"), + ("Times New Roman", "Tinos"), + ("Georgia", "Gelasio"), +]; + +/// Each proprietary name must resolve to its metric-compatible substitute +/// (unless the proprietary face is genuinely installed on this machine, in +/// which case resolving to itself is correct and no substitution is recorded). +#[test] +fn proprietary_names_resolve_to_bundled_substitutes() { + let mut fonts = FontResources::new(); + for (requested, substitute) in SUBSTITUTION_TABLE { + let resolved = fonts.resolve_font_name(requested); + if resolved == *requested { + // The real face is installed here — legitimate, but then no + // substitution may be recorded for it. + assert_eq!( + fonts.substitutions.get(*requested), + None, + "{requested} resolved to itself but a substitution was recorded" + ); + } else { + assert_eq!( + &resolved, substitute, + "{requested} must substitute to {substitute}, got {resolved}" + ); + } + } +} + +/// A substitution must be *recorded* — the editor's warning banner reads +/// `FontResources::substitutions`, so a silent swap would hide the change +/// from the user. +#[test] +fn substitutions_are_recorded_for_the_warning_banner() { + let mut fonts = FontResources::new(); + for (requested, substitute) in SUBSTITUTION_TABLE { + let resolved = fonts.resolve_font_name(requested); + if resolved != *requested { + assert_eq!( + fonts.substitutions.get(*requested), + Some(&Some((*substitute).to_string())), + "{requested} → {substitute} substitution must be recorded" + ); + } + } +} + +/// The resolved family must be layout-usable: present in the Fontique +/// collection with at least one face. +#[test] +fn resolved_families_are_available_for_layout() { + let mut fonts = FontResources::new(); + for (requested, _) in SUBSTITUTION_TABLE { + let resolved = fonts.resolve_font_name(requested); + assert!( + fonts.font_cx.collection.family_id(&resolved).is_some(), + "{requested} resolved to {resolved}, which is not in the collection" + ); + } +} + +/// A family with no known substitute resolves to itself and is recorded as +/// unresolved (`None`), which the warning banner reports as "missing". +#[test] +fn unknown_family_is_recorded_as_unresolved() { + let mut fonts = FontResources::new(); + let resolved = fonts.resolve_font_name("Loki Nonexistent Face 2026"); + assert_eq!(resolved, "Loki Nonexistent Face 2026"); + assert_eq!( + fonts.substitutions.get("Loki Nonexistent Face 2026"), + Some(&None), + "an unresolvable family must be recorded as missing" + ); +} diff --git a/loki-odf/tests/schema_validation.rs b/loki-odf/tests/schema_validation.rs new file mode 100644 index 00000000..0f8856fb --- /dev/null +++ b/loki-odf/tests/schema_validation.rs @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Spec 02 schema axis (M2) — real ODT exports validate against the vendored +//! OASIS ODF 1.3 RELAX NG schemas. +//! +//! Every part the ODT writer emits is checked against the official schema via +//! `appthere_conformance`'s `XmllintValidator` (libxml2). A missing `xmllint` +//! fails loudly rather than skipping (Spec 02 §5); CI installs +//! `libxml2-utils`. The schemas are vendored + version-pinned under +//! `appthere-conformance/schemas/` (D6) — see its `README.md` / PROVENANCE. + +use std::io::{Cursor, Read}; +use std::path::PathBuf; + +use appthere_conformance::{SchemaKind, SchemaValidator, XmllintValidator}; +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::content::table::core::{Table, TableBody, TableCaption, TableFoot, TableHead}; +use loki_doc_model::content::table::row::{Cell, Row}; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::section::Section; +use loki_doc_model::style::props::char_props::CharProps; +use loki_odf::odt::export::{OdtExport, OdtExportOptions}; + +fn schema(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../appthere-conformance/schemas/odf") + .join(name) +} + +/// A document exercising the writer's main surfaces: heading, plain and +/// styled paragraphs, and a table. +fn sample_document() -> Document { + let bold = CharProps { + bold: Some(true), + ..Default::default() + }; + let table = Block::Table(Box::new(Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: vec![], + head: TableHead { + attr: NodeAttr::default(), + rows: vec![], + }, + bodies: vec![TableBody { + attr: NodeAttr::default(), + head_rows: vec![], + body_rows: vec![Row::new(vec![ + Cell::simple(vec![Block::Para(vec![Inline::Str("a".into())])]), + Cell::simple(vec![Block::Para(vec![Inline::Str("b".into())])]), + ])], + }], + foot: TableFoot { + attr: NodeAttr::default(), + rows: vec![], + }, + })); + let mut d = Document::default(); + let mut s = Section::new(); + s.blocks = vec![ + Block::Heading(1, NodeAttr::default(), vec![Inline::Str("Title".into())]), + Block::Para(vec![ + Inline::Str("Plain then ".into()), + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(bold)), + content: vec![Inline::Str("bold".into())], + attr: NodeAttr::default(), + }), + ]), + table, + ]; + d.sections = vec![s]; + d +} + +fn export_odt(doc: &Document) -> Vec { + let mut buf = Cursor::new(Vec::new()); + OdtExport::export(doc, &mut buf, OdtExportOptions::default()) + .expect("ODT export should succeed"); + buf.into_inner() +} + +/// Extracts a named part from the exported ODT ZIP. +fn part(odt: &[u8], name: &str) -> Vec { + let mut zip = zip::ZipArchive::new(Cursor::new(odt)).expect("exported ODT must be a ZIP"); + let mut file = zip + .by_name(name) + .unwrap_or_else(|_| panic!("exported ODT must contain {name}")); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).expect("read part"); + bytes +} + +fn assert_part_valid(part_name: &str, schema_name: &str) { + let odt = export_odt(&sample_document()); + let xml = part(&odt, part_name); + let validator = XmllintValidator::new().expect("xmllint must be installed (libxml2-utils)"); + let report = validator + .validate_bytes(&xml, &schema(schema_name), SchemaKind::RelaxNg) + .expect("validation must run"); + assert!( + report.valid, + "{part_name} must be ODF-1.3-schema-valid; violations: {:#?}", + report.violations + ); +} + +#[test] +fn odt_content_xml_is_schema_valid() { + assert_part_valid("content.xml", "OpenDocument-v1.3-schema.rng"); +} + +#[test] +fn odt_styles_xml_is_schema_valid() { + assert_part_valid("styles.xml", "OpenDocument-v1.3-schema.rng"); +} + +#[test] +fn odt_meta_xml_is_schema_valid() { + assert_part_valid("meta.xml", "OpenDocument-v1.3-schema.rng"); +} + +#[test] +fn odt_manifest_is_schema_valid() { + assert_part_valid( + "META-INF/manifest.xml", + "OpenDocument-v1.3-manifest-schema.rng", + ); +} + +/// M2 acceptance: a deliberately malformed part must FAIL the gate (guards +/// against a validator that silently passes everything). +#[test] +fn deliberately_malformed_content_fails_the_gate() { + let odt = export_odt(&sample_document()); + let xml = String::from_utf8(part(&odt, "content.xml")).expect("content.xml is UTF-8"); + let broken = xml.replacen("", "", 1); + assert_ne!(xml, broken, "the malformation must have been injected"); + let validator = XmllintValidator::new().expect("xmllint must be installed"); + let report = validator + .validate_bytes( + broken.as_bytes(), + &schema("OpenDocument-v1.3-schema.rng"), + SchemaKind::RelaxNg, + ) + .expect("validation must run"); + assert!( + !report.valid, + "an invented office:bogus-element must be rejected by the ODF schema" + ); +} diff --git a/loki-ooxml/tests/schema_validation.rs b/loki-ooxml/tests/schema_validation.rs new file mode 100644 index 00000000..06b56546 --- /dev/null +++ b/loki-ooxml/tests/schema_validation.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Spec 02 schema axis (M2) — real DOCX exports validate against the vendored +//! ECMA-376 Transitional XSDs. +//! +//! The main WordprocessingML parts the DOCX writer emits are checked against +//! the official schema via `appthere_conformance`'s `XmllintValidator` +//! (libxml2). A missing `xmllint` fails loudly rather than skipping (Spec 02 +//! §5); CI installs `libxml2-utils`. The schemas are vendored + +//! version-pinned under `appthere-conformance/schemas/` (D6). + +use std::io::{Cursor, Read}; +use std::path::PathBuf; + +use appthere_conformance::{SchemaKind, SchemaValidator, XmllintValidator}; +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::section::Section; +use loki_doc_model::style::props::char_props::CharProps; +use loki_ooxml::docx::export::DocxExport; + +fn schema_wml() -> PathBuf { + // Canonicalized so every xsd:import resolves to one canonical location — + // libxml2 treats the same file reached via two path spellings as two + // schema documents and then skips "duplicate" namespace imports. + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../appthere-conformance/schemas/ooxml/transitional/wml.xsd") + .canonicalize() + .expect("vendored wml.xsd must exist") +} + +/// A document exercising the writer's main WordprocessingML surfaces. +fn sample_document() -> Document { + let bold = CharProps { + bold: Some(true), + ..Default::default() + }; + let mut d = Document::default(); + let mut s = Section::new(); + s.blocks = vec![ + Block::Heading(1, NodeAttr::default(), vec![Inline::Str("Title".into())]), + Block::Para(vec![ + Inline::Str("Plain then ".into()), + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(bold)), + content: vec![Inline::Str("bold".into())], + attr: NodeAttr::default(), + }), + ]), + ]; + d.sections = vec![s]; + d +} + +fn export_docx(doc: &Document) -> Vec { + let mut buf = Cursor::new(Vec::new()); + DocxExport::export(doc, &mut buf, ()).expect("DOCX export should succeed"); + buf.into_inner() +} + +fn part(docx: &[u8], name: &str) -> Vec { + let mut zip = zip::ZipArchive::new(Cursor::new(docx)).expect("exported DOCX must be a ZIP"); + let mut file = zip + .by_name(name) + .unwrap_or_else(|_| panic!("exported DOCX must contain {name}")); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).expect("read part"); + bytes +} + +fn assert_part_valid(part_name: &str) { + let docx = export_docx(&sample_document()); + let xml = part(&docx, part_name); + let validator = XmllintValidator::new().expect("xmllint must be installed (libxml2-utils)"); + let report = validator + .validate_bytes(&xml, &schema_wml(), SchemaKind::Xsd) + .expect("validation must run"); + assert!( + report.valid, + "{part_name} must be ECMA-376-Transitional-valid; violations: {:#?}", + report.violations + ); +} + +#[test] +fn docx_document_xml_is_schema_valid() { + assert_part_valid("word/document.xml"); +} + +#[test] +fn docx_styles_xml_is_schema_valid() { + assert_part_valid("word/styles.xml"); +} + +fn schema_opc(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../appthere-conformance/schemas/opc") + .join(name) + .canonicalize() + .expect("vendored OPC schema must exist") +} + +fn assert_opc_part_valid(part_name: &str, schema_name: &str) { + let docx = export_docx(&sample_document()); + let xml = part(&docx, part_name); + let validator = XmllintValidator::new().expect("xmllint must be installed"); + let report = validator + .validate_bytes(&xml, &schema_opc(schema_name), SchemaKind::Xsd) + .expect("validation must run"); + assert!( + report.valid, + "{part_name} must be OPC-schema-valid; violations: {:#?}", + report.violations + ); +} + +/// The OPC package layer (`loki-opc` output) must satisfy ECMA-376 Part 2. +#[test] +fn docx_content_types_are_opc_schema_valid() { + assert_opc_part_valid("[Content_Types].xml", "opc-contentTypes.xsd"); +} + +#[test] +fn docx_package_relationships_are_opc_schema_valid() { + assert_opc_part_valid("_rels/.rels", "opc-relationships.xsd"); +} + +// TODO(conformance-schemas): validate docProps/core.xml against +// opc-coreProperties.xsd once the Dublin Core XSDs it imports (dc.xsd, +// dcterms.xsd, dcmitype.xsd) are vendored — the schema references them by +// live dublincore.org URL, which offline validation (D6) cannot follow, and +// no in-policy source for them was reachable from this environment. + +/// M2 acceptance: a deliberately malformed part must FAIL the gate. +#[test] +fn deliberately_malformed_document_fails_the_gate() { + let docx = export_docx(&sample_document()); + let xml = String::from_utf8(part(&docx, "word/document.xml")).expect("document.xml is UTF-8"); + let broken = xml.replacen("", "", 1); + assert_ne!(xml, broken, "the malformation must have been injected"); + let validator = XmllintValidator::new().expect("xmllint must be installed"); + let report = validator + .validate_bytes(broken.as_bytes(), &schema_wml(), SchemaKind::Xsd) + .expect("validation must run"); + assert!( + !report.valid, + "an invented w:bogus-element must be rejected by the WML schema" + ); +} From bb800c42fb360b58a84a251f69fc691d4bf653ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:14:39 +0000 Subject: [PATCH 09/27] feat(conformance): pinned PDF->PNG rasterizer + SSIM/dE worst-region differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.3 (B-5/D3): appthere_conformance::raster::PdfRasterizer — the one shared PDF->PNG stage both reference apps rasterize through, so golden and candidate differ only in the layout/render engine. Backend is poppler pdftoppm at the fixed CONFORMANCE_DPI (144) with anti-aliasing pinned explicitly; the binary's absence fails loudly and its version string is captured for golden/calibration metadata. Tests include byte-determinism of two runs over the same PDF. Phase 3.4 (B-4/§7.4): golden::diff — the perceptual differ. Windowed SSIM promoted from loki-acid; new CIEDE2000 dE (sRGB->Lab D65 + Sharma 2005 formulation, verified against the published reference pairs to 1e-4); 64px regional scoring where the WORST region drives the result (test proves a 16px defect fails a page whose page-wide mean would have passed); Tolerance carries the thresholds (calibrated record lands with M5 — no guessed default constant); failure heatmaps (dE-scaled red over dimmed golden). All pure Rust, GPU-free. Phases 3.3 + 3.4 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- Cargo.lock | 1 + appthere-conformance/Cargo.toml | 2 + appthere-conformance/src/golden/diff.rs | 330 ++++++++++++++++++ appthere-conformance/src/golden/diff_tests.rs | 151 ++++++++ appthere-conformance/src/golden/mod.rs | 4 + appthere-conformance/src/lib.rs | 2 + appthere-conformance/src/raster.rs | 135 +++++++ appthere-conformance/src/raster_tests.rs | 66 ++++ 8 files changed, 691 insertions(+) create mode 100644 appthere-conformance/src/golden/diff.rs create mode 100644 appthere-conformance/src/golden/diff_tests.rs create mode 100644 appthere-conformance/src/raster.rs create mode 100644 appthere-conformance/src/raster_tests.rs diff --git a/Cargo.lock b/Cargo.lock index d1438b87..46dbde68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,7 @@ dependencies = [ name = "appthere-conformance" version = "0.1.0" dependencies = [ + "image", "loki-doc-model", "loki-sheet-model", "tempfile", diff --git a/appthere-conformance/Cargo.toml b/appthere-conformance/Cargo.toml index 28daf5e4..b721fbe1 100644 --- a/appthere-conformance/Cargo.toml +++ b/appthere-conformance/Cargo.toml @@ -21,6 +21,8 @@ sheet-model = ["dep:loki-sheet-model"] thiserror = { workspace = true } # Round-trip the candidate XML through a temp file for `xmllint` validation. tempfile = "3" +# PNG decode/encode for the visual-goldens differ and heatmap emission. +image = { version = "0.25", default-features = false, features = ["png"] } # Optional: the format-neutral document model, for the round-trip canonicalizer. loki-doc-model = { path = "../loki-doc-model", optional = true } # Optional: the spreadsheet model, for the workbook round-trip canonicalizer. diff --git a/appthere-conformance/src/golden/diff.rs b/appthere-conformance/src/golden/diff.rs new file mode 100644 index 00000000..f68b24f2 --- /dev/null +++ b/appthere-conformance/src/golden/diff.rs @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The perceptual differ (Spec 02 §7.4 / B-4): SSIM **and** CIEDE2000 ΔE, +//! scored per tiled region with the **worst region driving the result**, plus +//! failure-heatmap emission. +//! +//! The windowed-SSIM core is promoted from `loki-acid`'s `diff.rs` (M1 — +//! "promote, don't greenfield"); the ΔE metric, regional worst-region +//! scoring, tolerances, and heatmaps are the Spec 02 extensions. Everything +//! is pure Rust over decoded RGBA — no GPU, no external tools. + +use image::RgbaImage; + +use super::{PerceptualReport, RegionScore}; + +/// Window side length (pixels) for windowed SSIM. +const WINDOW: u32 = 8; +/// Region (tile) side length in pixels: the granularity at which the worst +/// region is selected. One mis-rendered glyph lands in a 64×64 tile and fails +/// that tile, however large and correct the rest of the page is. +const REGION: u32 = 64; + +/// SSIM stabilisation constants on the 0–255 luminance scale. +const C1: f64 = (0.01 * 255.0) * (0.01 * 255.0); +const C2: f64 = (0.03 * 255.0) * (0.03 * 255.0); + +/// Error returned when two images cannot be compared. +#[derive(Debug, thiserror::Error)] +pub enum DiffError { + /// The two images have different dimensions. + #[error("dimension mismatch: golden {golden:?} vs candidate {candidate:?}")] + DimensionMismatch { + /// Golden image dimensions. + golden: (u32, u32), + /// Candidate image dimensions. + candidate: (u32, u32), + }, + /// Writing the failure heatmap failed. + #[error("failed to write heatmap: {0}")] + Heatmap(#[source] image::ImageError), +} + +/// Pass thresholds for one comparison. +/// +/// The default threshold must come from the committed calibration record +/// (Spec 02 D5) — never a guessed literal. Until that record lands (M5 +/// calibration pass), construct explicitly; a per-test override must carry a +/// comment justifying it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Tolerance { + /// Minimum acceptable per-region SSIM. + pub min_ssim: f64, + /// Maximum acceptable per-region mean CIEDE2000 ΔE. + pub max_delta_e: f64, +} + +/// Converts an RGBA image to a row-major luminance buffer (BT.601, 0–255). +#[must_use] +pub fn to_luma(img: &RgbaImage) -> Vec { + img.pixels() + .map(|p| { + let [r, g, b, _] = p.0; + 0.299 * f64::from(r) + 0.587 * f64::from(g) + 0.114 * f64::from(b) + }) + .collect() +} + +/// SSIM over a single rectangular window of two luminance buffers. +fn window_ssim(la: &[f64], lb: &[f64], stride: u32, x0: u32, y0: u32, w: u32, h: u32) -> f64 { + let n = f64::from(w * h); + let (mut sa, mut sb) = (0.0, 0.0); + for yy in 0..h { + for xx in 0..w { + let i = ((y0 + yy) * stride + (x0 + xx)) as usize; + sa += la[i]; + sb += lb[i]; + } + } + let (mu_a, mu_b) = (sa / n, sb / n); + let (mut va, mut vb, mut cov) = (0.0, 0.0, 0.0); + for yy in 0..h { + for xx in 0..w { + let i = ((y0 + yy) * stride + (x0 + xx)) as usize; + let (da, db) = (la[i] - mu_a, lb[i] - mu_b); + va += da * da; + vb += db * db; + cov += da * db; + } + } + va /= n; + vb /= n; + cov /= n; + ((2.0 * mu_a * mu_b + C1) * (2.0 * cov + C2)) + / ((mu_a * mu_a + mu_b * mu_b + C1) * (va + vb + C2)) +} + +/// Compares a candidate page against its golden: every [`REGION`]-sized tile +/// is scored (SSIM via [`WINDOW`]-sized sub-windows, mean CIEDE2000 ΔE per +/// pixel), and the page passes iff **every** region meets `tol`. +pub fn compare_pages( + golden: &RgbaImage, + candidate: &RgbaImage, + tol: Tolerance, +) -> Result { + if golden.dimensions() != candidate.dimensions() { + return Err(DiffError::DimensionMismatch { + golden: golden.dimensions(), + candidate: candidate.dimensions(), + }); + } + let (w, h) = golden.dimensions(); + let la = to_luma(golden); + let lb = to_luma(candidate); + + let mut regions = Vec::new(); + let mut worst: Option = None; + let mut passed = true; + + let (cols, rows) = (w.div_ceil(REGION).max(1), h.div_ceil(REGION).max(1)); + for row in 0..rows { + for col in 0..cols { + let x0 = col * REGION; + let y0 = row * REGION; + let rw = REGION.min(w - x0); + let rh = REGION.min(h - y0); + let ssim = region_ssim(&la, &lb, w, x0, y0, rw, rh); + let delta_e = region_delta_e(golden, candidate, x0, y0, rw, rh); + let score = RegionScore { + region: (col, row), + ssim, + delta_e, + }; + let fails = ssim < tol.min_ssim || delta_e > tol.max_delta_e; + passed &= !fails; + worst = Some(match worst { + None => score, + Some(prev) => { + // The "worst" region is the one that violates hardest: + // failing beats passing; among equals, lower SSIM, then + // higher ΔE. + let prev_fails = prev.ssim < tol.min_ssim || prev.delta_e > tol.max_delta_e; + let worse = match (fails, prev_fails) { + (true, false) => true, + (false, true) => false, + _ => { + score.ssim < prev.ssim + || (score.ssim == prev.ssim && score.delta_e > prev.delta_e) + } + }; + if worse { score } else { prev } + } + }); + regions.push(score); + } + } + + Ok(PerceptualReport { + regions, + worst, + passed, + heatmap: None, + }) +} + +/// Mean of the [`WINDOW`]-sized SSIM sub-windows covering one region (windows +/// clipped to the region; a region smaller than one window is one window). +fn region_ssim(la: &[f64], lb: &[f64], stride: u32, x0: u32, y0: u32, w: u32, h: u32) -> f64 { + if w < WINDOW || h < WINDOW { + return window_ssim(la, lb, stride, x0, y0, w, h); + } + let mut sum = 0.0; + let mut count = 0u64; + let mut y = 0; + while y + WINDOW <= h { + let mut x = 0; + while x + WINDOW <= w { + sum += window_ssim(la, lb, stride, x0 + x, y0 + y, WINDOW, WINDOW); + count += 1; + x += WINDOW; + } + y += WINDOW; + } + if count == 0 { 1.0 } else { sum / count as f64 } +} + +/// Mean per-pixel CIEDE2000 ΔE over one region. +fn region_delta_e(a: &RgbaImage, b: &RgbaImage, x0: u32, y0: u32, w: u32, h: u32) -> f64 { + let mut sum = 0.0; + for yy in 0..h { + for xx in 0..w { + let pa = a.get_pixel(x0 + xx, y0 + yy).0; + let pb = b.get_pixel(x0 + xx, y0 + yy).0; + sum += ciede2000(srgb_to_lab(pa), srgb_to_lab(pb)); + } + } + sum / f64::from(w * h) +} + +/// Emits a failure heatmap: the golden as a dimmed grayscale base with +/// per-pixel ΔE painted red (intensity ∝ ΔE, saturating at ΔE = 20). +pub fn emit_heatmap( + golden: &RgbaImage, + candidate: &RgbaImage, + path: &std::path::Path, +) -> Result<(), DiffError> { + let (w, h) = golden.dimensions(); + let mut out = RgbaImage::new(w, h); + for y in 0..h { + for x in 0..w { + let pg = golden.get_pixel(x, y).0; + let pc = candidate.get_pixel(x, y).0; + let de = ciede2000(srgb_to_lab(pg), srgb_to_lab(pc)); + let luma = + (0.299 * f64::from(pg[0]) + 0.587 * f64::from(pg[1]) + 0.114 * f64::from(pg[2])) + * 0.5; + let heat = (de / 20.0).min(1.0); + let r = (luma + (255.0 - luma) * heat) as u8; + let gb = (luma * (1.0 - heat)) as u8; + out.put_pixel(x, y, image::Rgba([r, gb, gb, 255])); + } + } + out.save(path).map_err(DiffError::Heatmap) +} + +// ── sRGB → CIELAB (D65) and CIEDE2000 ──────────────────────────────────────── + +/// Converts an 8-bit sRGB pixel to CIELAB (D65 white point). +fn srgb_to_lab([r, g, b, _]: [u8; 4]) -> [f64; 3] { + fn linear(c: u8) -> f64 { + let c = f64::from(c) / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + let (rl, gl, bl) = (linear(r), linear(g), linear(b)); + // sRGB D65 → XYZ. + let x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl; + let y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl; + let z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl; + // XYZ → Lab with the D65 reference white. + fn f(t: f64) -> f64 { + const DELTA: f64 = 6.0 / 29.0; + if t > DELTA * DELTA * DELTA { + t.cbrt() + } else { + t / (3.0 * DELTA * DELTA) + 4.0 / 29.0 + } + } + let (xn, yn, zn) = (0.95047, 1.0, 1.08883); + let (fx, fy, fz) = (f(x / xn), f(y / yn), f(z / zn)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// CIEDE2000 colour difference (Sharma et al. 2005 formulation). +#[allow(clippy::many_single_char_names, clippy::similar_names)] +fn ciede2000([l1, a1, b1]: [f64; 3], [l2, a2, b2]: [f64; 3]) -> f64 { + let c1 = (a1 * a1 + b1 * b1).sqrt(); + let c2 = (a2 * a2 + b2 * b2).sqrt(); + let c_bar = (c1 + c2) / 2.0; + let c_bar7 = c_bar.powi(7); + let g = 0.5 * (1.0 - (c_bar7 / (c_bar7 + 25.0_f64.powi(7))).sqrt()); + let ap1 = (1.0 + g) * a1; + let ap2 = (1.0 + g) * a2; + let cp1 = (ap1 * ap1 + b1 * b1).sqrt(); + let cp2 = (ap2 * ap2 + b2 * b2).sqrt(); + let hp = |ap: f64, b: f64| -> f64 { + if ap == 0.0 && b == 0.0 { + 0.0 + } else { + let h = b.atan2(ap).to_degrees(); + if h < 0.0 { h + 360.0 } else { h } + } + }; + let hp1 = hp(ap1, b1); + let hp2 = hp(ap2, b2); + + let dl = l2 - l1; + let dc = cp2 - cp1; + let dhp = if cp1 * cp2 == 0.0 { + 0.0 + } else { + let d = hp2 - hp1; + if d.abs() <= 180.0 { + d + } else if d > 180.0 { + d - 360.0 + } else { + d + 360.0 + } + }; + let dh = 2.0 * (cp1 * cp2).sqrt() * (dhp.to_radians() / 2.0).sin(); + + let l_bar = (l1 + l2) / 2.0; + let cp_bar = (cp1 + cp2) / 2.0; + let hp_bar = if cp1 * cp2 == 0.0 { + hp1 + hp2 + } else { + let sum = hp1 + hp2; + let d = (hp1 - hp2).abs(); + if d <= 180.0 { + sum / 2.0 + } else if sum < 360.0 { + (sum + 360.0) / 2.0 + } else { + (sum - 360.0) / 2.0 + } + }; + + let t = 1.0 - 0.17 * (hp_bar - 30.0).to_radians().cos() + + 0.24 * (2.0 * hp_bar).to_radians().cos() + + 0.32 * (3.0 * hp_bar + 6.0).to_radians().cos() + - 0.20 * (4.0 * hp_bar - 63.0).to_radians().cos(); + let d_theta = 30.0 * (-((hp_bar - 275.0) / 25.0).powi(2)).exp(); + let cp_bar7 = cp_bar.powi(7); + let rc = 2.0 * (cp_bar7 / (cp_bar7 + 25.0_f64.powi(7))).sqrt(); + let lb50 = (l_bar - 50.0).powi(2); + let sl = 1.0 + 0.015 * lb50 / (20.0 + lb50).sqrt(); + let sc = 1.0 + 0.045 * cp_bar; + let sh = 1.0 + 0.015 * cp_bar * t; + let rt = -(2.0 * d_theta).to_radians().sin() * rc; + + ((dl / sl).powi(2) + (dc / sc).powi(2) + (dh / sh).powi(2) + rt * (dc / sc) * (dh / sh)).sqrt() +} + +#[cfg(test)] +#[path = "diff_tests.rs"] +mod tests; diff --git a/appthere-conformance/src/golden/diff_tests.rs b/appthere-conformance/src/golden/diff_tests.rs new file mode 100644 index 00000000..d80885a4 --- /dev/null +++ b/appthere-conformance/src/golden/diff_tests.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use super::*; +use image::Rgba; + +fn solid(w: u32, h: u32, c: [u8; 4]) -> RgbaImage { + RgbaImage::from_pixel(w, h, Rgba(c)) +} + +/// A permissive tolerance for structural tests (thresholds themselves are +/// calibrated separately — D5). +fn tol() -> Tolerance { + Tolerance { + min_ssim: 0.9, + max_delta_e: 4.0, + } +} + +// ── SSIM core (promoted from loki-acid) ─────────────────────────────────────── + +#[test] +fn identical_images_pass_with_ssim_one() { + let img = solid(128, 128, [120, 130, 140, 255]); + let report = compare_pages(&img, &img, tol()).unwrap(); + assert!(report.passed); + let worst = report.worst.unwrap(); + assert!((worst.ssim - 1.0).abs() < 1e-9, "ssim={}", worst.ssim); + assert!(worst.delta_e < 1e-9, "delta_e={}", worst.delta_e); +} + +#[test] +fn black_vs_white_fails_hard() { + let a = solid(128, 128, [0, 0, 0, 255]); + let b = solid(128, 128, [255, 255, 255, 255]); + let report = compare_pages(&a, &b, tol()).unwrap(); + assert!(!report.passed); + assert!(report.worst.unwrap().ssim < 0.05); +} + +#[test] +fn dimension_mismatch_errors() { + let a = solid(64, 64, [0, 0, 0, 255]); + let b = solid(65, 64, [0, 0, 0, 255]); + assert!(compare_pages(&a, &b, tol()).is_err()); +} + +// ── Worst-region semantics (the B-4 fix) ────────────────────────────────────── + +/// A small localized defect must fail the page even though the page-wide +/// *mean* would comfortably pass — the worst region drives the result. +#[test] +fn localized_defect_is_not_averaged_away() { + let golden = solid(256, 256, [255, 255, 255, 255]); + let mut candidate = golden.clone(); + for y in 32..48 { + for x in 32..48 { + candidate.put_pixel(x, y, Rgba([0, 0, 0, 255])); + } + } + let report = compare_pages(&golden, &candidate, tol()).unwrap(); + assert!(!report.passed, "a 16px black square must fail its region"); + // The defect lies in region (0, 0) (64px tiles). + let worst = report.worst.unwrap(); + assert_eq!(worst.region, (0, 0), "worst region must be the defect tile"); + // Every *other* region is perfect — proving the mean would have passed. + let clean: Vec<_> = report + .regions + .iter() + .filter(|r| r.region != (0, 0)) + .collect(); + assert!(clean.iter().all(|r| (r.ssim - 1.0).abs() < 1e-9)); + let mean: f64 = + report.regions.iter().map(|r| r.ssim).sum::() / report.regions.len() as f64; + assert!( + mean > tol().min_ssim, + "the page-wide mean ({mean}) would have masked the defect" + ); +} + +/// A colour shift with intact structure is caught by ΔE, not SSIM: same +/// geometry, hue-shifted page. +#[test] +fn colour_shift_is_caught_by_delta_e() { + let golden = solid(128, 128, [200, 60, 60, 255]); + let candidate = solid(128, 128, [60, 200, 60, 255]); + let report = compare_pages(&golden, &candidate, tol()).unwrap(); + assert!(!report.passed, "a hue swap must fail on ΔE"); + let worst = report.worst.unwrap(); + assert!( + worst.delta_e > tol().max_delta_e, + "delta_e={} must exceed the bound", + worst.delta_e + ); +} + +// ── CIEDE2000 reference values (Sharma et al. 2005 test data) ───────────────── + +#[test] +fn ciede2000_matches_reference_pairs() { + // (Lab1, Lab2, expected ΔE00) from the published CIEDE2000 test dataset. + let cases = [ + ([50.0, 2.6772, -79.7751], [50.0, 0.0, -82.7485], 2.0425), + ([50.0, 3.1571, -77.2803], [50.0, 0.0, -82.7485], 2.8615), + ([50.0, 2.8361, -74.0200], [50.0, 0.0, -82.7485], 3.4412), + ]; + for (lab1, lab2, expected) in cases { + let de = ciede2000(lab1, lab2); + assert!( + (de - expected).abs() < 1e-4, + "ΔE00({lab1:?}, {lab2:?}) = {de}, expected {expected}" + ); + let sym = ciede2000(lab2, lab1); + assert!((de - sym).abs() < 1e-9, "ΔE00 must be symmetric"); + } +} + +#[test] +fn srgb_to_lab_anchors() { + let [l, a, b] = srgb_to_lab([255, 255, 255, 255]); + assert!((l - 100.0).abs() < 0.01, "white L={l}"); + assert!(a.abs() < 0.01 && b.abs() < 0.01, "white a={a} b={b}"); + let [l, a, b] = srgb_to_lab([0, 0, 0, 255]); + assert!( + l.abs() < 1e-6 && a.abs() < 1e-6 && b.abs() < 1e-6, + "black {l} {a} {b}" + ); +} + +// ── Heatmap emission ────────────────────────────────────────────────────────── + +#[test] +fn heatmap_marks_the_defect_hot() { + let golden = solid(128, 128, [255, 255, 255, 255]); + let mut candidate = golden.clone(); + for y in 8..24 { + for x in 8..24 { + candidate.put_pixel(x, y, Rgba([0, 0, 0, 255])); + } + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("heat.png"); + emit_heatmap(&golden, &candidate, &path).unwrap(); + let heat = image::open(&path).unwrap().to_rgba8(); + let hot = heat.get_pixel(16, 16).0; + let cold = heat.get_pixel(100, 100).0; + assert!( + hot[0] > cold[0].saturating_add(50) || (hot[0] > 200 && hot[1] < 100), + "defect pixel must be visibly hotter: hot={hot:?} cold={cold:?}" + ); +} diff --git a/appthere-conformance/src/golden/mod.rs b/appthere-conformance/src/golden/mod.rs index 1cd6afca..1ade4e93 100644 --- a/appthere-conformance/src/golden/mod.rs +++ b/appthere-conformance/src/golden/mod.rs @@ -19,6 +19,10 @@ use std::path::PathBuf; +pub mod diff; + +pub use diff::{DiffError, Tolerance, compare_pages, emit_heatmap}; + /// A perceptual score for one tiled region of a page. #[derive(Clone, Copy, Debug, PartialEq)] pub struct RegionScore { diff --git a/appthere-conformance/src/lib.rs b/appthere-conformance/src/lib.rs index 238e82ce..155b85df 100644 --- a/appthere-conformance/src/lib.rs +++ b/appthere-conformance/src/lib.rs @@ -37,11 +37,13 @@ pub mod golden; #[cfg(feature = "doc-model")] pub mod model; +pub mod raster; pub mod roundtrip; pub mod schema; #[cfg(feature = "sheet-model")] pub mod sheet; +pub use raster::{CONFORMANCE_DPI, PdfRasterizer, RasterError}; pub use roundtrip::{CanonicalEntry, Divergence, NormalizedModel, first_divergence}; pub use schema::xmllint::XmllintValidator; pub use schema::{SchemaError, SchemaKind, SchemaReport, SchemaValidator, SchemaViolation}; diff --git a/appthere-conformance/src/raster.rs b/appthere-conformance/src/raster.rs new file mode 100644 index 00000000..63c1ca79 --- /dev/null +++ b/appthere-conformance/src/raster.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The shared, pinned PDF→PNG rasterizer (Spec 02 **D3** / B-5). +//! +//! Both reference apps produce their goldens *via PDF* (Word COM → PDF, +//! `soffice --headless` → PDF), and every PDF is rasterized to PNG through +//! **this one wrapper** at the fixed conformance DPI — so the golden and +//! candidate sides differ only in the layout/render engine, never in the PNG +//! encoder, DPI, or anti-aliasing settings. +//! +//! The backend is poppler's `pdftoppm`. Its availability and version are +//! checked explicitly and *recorded* — a missing binary fails loudly (the +//! same policy as the schema axis' `xmllint`), and the version string is +//! written into golden/calibration metadata so a poppler upgrade that shifts +//! the noise floor is visible in the record, not folklore. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The fixed DPI every conformance render and golden uses (Spec 02 §7.1: a +/// fixed DPI and pinned anti-aliasing settings). 144 dpi = 2× CSS pixel +/// density: crisp enough for glyph-level diffs, small enough to commit. +pub const CONFORMANCE_DPI: u32 = 144; + +/// Errors from the pinned rasterizer. +#[derive(Debug, thiserror::Error)] +pub enum RasterError { + /// `pdftoppm` was not found on `PATH`. Fails loudly, never skips. + #[error( + "pdftoppm (poppler-utils) not found on PATH — install poppler-utils; \ + golden rasterization must not be silently skipped" + )] + PdftoppmNotFound, + /// Spawning or running `pdftoppm` failed. + #[error("failed to run pdftoppm: {0}")] + Spawn(#[source] std::io::Error), + /// `pdftoppm` exited unsuccessfully. + #[error("pdftoppm failed (code {code:?}): {stderr}")] + Failed { + /// Process exit code, if any. + code: Option, + /// Captured stderr. + stderr: String, + }, + /// The run produced no pages. + #[error("pdftoppm produced no PNG pages for {0}")] + NoPages(PathBuf), + /// Filesystem error handling inputs/outputs. + #[error("rasterizer I/O error: {0}")] + Io(#[source] std::io::Error), +} + +/// The pinned PDF→PNG stage. Construct once; the captured version string +/// belongs in golden/calibration metadata. +pub struct PdfRasterizer { + version: String, +} + +impl PdfRasterizer { + /// Locates `pdftoppm` and captures its version. Errors if absent. + pub fn new() -> Result { + let out = Command::new("pdftoppm") + .arg("-v") + .output() + .map_err(|_| RasterError::PdftoppmNotFound)?; + // pdftoppm prints its version banner on stderr. + let banner = String::from_utf8_lossy(&out.stderr); + let version = banner + .lines() + .next() + .unwrap_or("pdftoppm (unknown version)") + .trim() + .to_string(); + Ok(Self { version }) + } + + /// The recorded backend version (e.g. `pdftoppm version 24.02.0`). + #[must_use] + pub fn version(&self) -> &str { + &self.version + } + + /// Rasterizes every page of `pdf` to `out_dir/-N.png` at + /// [`CONFORMANCE_DPI`], with anti-aliasing pinned on. Returns the page + /// PNGs in page order. + pub fn rasterize( + &self, + pdf: &Path, + out_dir: &Path, + stem: &str, + ) -> Result, RasterError> { + std::fs::create_dir_all(out_dir).map_err(RasterError::Io)?; + let prefix = out_dir.join(stem); + let output = Command::new("pdftoppm") + .arg("-png") + .args(["-r", &CONFORMANCE_DPI.to_string()]) + // Pin the anti-aliasing settings explicitly (Spec 02 §7.1) so a + // poppler default change cannot silently move the noise floor. + .args(["-aa", "yes", "-aaVector", "yes"]) + .arg(pdf) + .arg(&prefix) + .output() + .map_err(RasterError::Spawn)?; + if !output.status.success() { + return Err(RasterError::Failed { + code: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + // pdftoppm names pages `-1.png`, `-2.png`, … (zero-padded once + // page counts grow); collect and sort them for a stable page order. + let mut pages: Vec = std::fs::read_dir(out_dir) + .map_err(RasterError::Io)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| { + p.extension().is_some_and(|x| x == "png") + && p.file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|s| s.starts_with(&format!("{stem}-"))) + }) + .collect(); + pages.sort(); + if pages.is_empty() { + return Err(RasterError::NoPages(pdf.to_path_buf())); + } + Ok(pages) + } +} + +#[cfg(test)] +#[path = "raster_tests.rs"] +mod tests; diff --git a/appthere-conformance/src/raster_tests.rs b/appthere-conformance/src/raster_tests.rs new file mode 100644 index 00000000..519b7707 --- /dev/null +++ b/appthere-conformance/src/raster_tests.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use super::*; + +/// A minimal but complete one-page PDF (blank A4-ish page) for exercising the +/// rasterizer without depending on an export crate. +const TINY_PDF: &[u8] = b"%PDF-1.4 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] >> endobj +xref +0 4 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +trailer << /Size 4 /Root 1 0 R >> +startxref +187 +%%EOF +"; + +#[test] +fn version_is_captured() { + let r = PdfRasterizer::new().expect("pdftoppm must be installed (poppler-utils)"); + assert!( + r.version().contains("pdftoppm"), + "version banner should identify the backend: {:?}", + r.version() + ); +} + +#[test] +fn rasterizes_a_page_deterministically() { + let r = PdfRasterizer::new().expect("pdftoppm must be installed"); + let dir = tempfile::tempdir().expect("tempdir"); + let pdf = dir.path().join("tiny.pdf"); + std::fs::write(&pdf, TINY_PDF).expect("write pdf"); + + let pages_a = r + .rasterize(&pdf, &dir.path().join("a"), "page") + .expect("rasterize A"); + assert_eq!(pages_a.len(), 1, "one page expected"); + + // D3's whole point: the stage is deterministic — two runs, identical bytes. + let pages_b = r + .rasterize(&pdf, &dir.path().join("b"), "page") + .expect("rasterize B"); + let a = std::fs::read(&pages_a[0]).expect("read A"); + let b = std::fs::read(&pages_b[0]).expect("read B"); + assert!(!a.is_empty()); + assert_eq!(a, b, "pinned rasterization must be byte-deterministic"); +} + +#[test] +fn missing_pages_is_an_error_not_empty_ok() { + let r = PdfRasterizer::new().expect("pdftoppm must be installed"); + let dir = tempfile::tempdir().expect("tempdir"); + let pdf = dir.path().join("broken.pdf"); + std::fs::write(&pdf, b"not a pdf").expect("write"); + assert!( + r.rasterize(&pdf, dir.path(), "page").is_err(), + "a broken PDF must error, not return zero pages" + ); +} From eed5759db93173b2007e9eb3742aad9d18c9859f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:23:35 +0000 Subject: [PATCH 10/27] feat(render-cpu): vello_cpu candidate render path (Spec 02 D2/M5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate loki-render-cpu: deterministic in-process CPU rasterization of loki-layout output — the conformance candidate side. Renders the same renderer-agnostic PositionedItem stream the GPU path paints, via vello_cpu (pinned =0.0.9) with no graphics adapter: glyph runs (baseline origin + per-glyph offsets, .notdef skipped, hinting off, VF default instance — each arm mirrors its loki-vello twin), filled/ border rects, decorations incl. the spelling squiggle wave, clipped and rotated groups. Editor chrome (page shadow, cursor, selection) is deliberately not document content and not rendered. Image items paint loki-vello's grey placeholder (TODO(conformance-render): decode). M5 acceptance smoke tests: a real Document laid out by Parley renders headless with actual glyph ink at the conformance DPI, byte-identical across runs. Phase 3.5 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- Cargo.lock | 161 ++++++++++++++-- Cargo.toml | 2 +- loki-render-cpu/Cargo.toml | 20 ++ loki-render-cpu/src/lib.rs | 157 ++++++++++++++++ loki-render-cpu/src/paint.rs | 261 ++++++++++++++++++++++++++ loki-render-cpu/tests/render_smoke.rs | 113 +++++++++++ 6 files changed, 693 insertions(+), 21 deletions(-) create mode 100644 loki-render-cpu/Cargo.toml create mode 100644 loki-render-cpu/src/lib.rs create mode 100644 loki-render-cpu/src/paint.rs create mode 100644 loki-render-cpu/tests/render_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index 46dbde68..bd9f0553 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,7 +301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e2c6900aa6fa601379c17b824d0882c5c4ffd2f974124b273ba083b64f76077" dependencies = [ "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "raw-window-handle", ] @@ -314,7 +314,7 @@ dependencies = [ "anyrender", "image", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "thiserror 2.0.18", "usvg", ] @@ -326,7 +326,7 @@ dependencies = [ "anyrender", "debug_timer", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "pollster", "rustc-hash 2.1.3", "vello", @@ -343,9 +343,9 @@ dependencies = [ "anyrender", "debug_timer", "kurbo 0.12.0", - "peniko", + "peniko 0.5.0", "softbuffer_window_renderer", - "vello_cpu", + "vello_cpu 0.0.4", ] [[package]] @@ -378,7 +378,7 @@ name = "appthere-canvas" version = "0.1.0" dependencies = [ "loki-render-cache", - "peniko", + "peniko 0.5.0", "read-fonts 0.40.2", ] @@ -993,7 +993,7 @@ dependencies = [ "euclid", "kurbo 0.12.0", "parley 0.6.0", - "peniko", + "peniko 0.5.0", "stylo", "taffy", "usvg", @@ -1016,7 +1016,7 @@ dependencies = [ "kurbo 0.12.0", "log", "parley 0.6.0", - "peniko", + "peniko 0.5.0", "rfd 0.17.2", "winit", ] @@ -2751,6 +2751,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -3230,6 +3236,23 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko 0.6.1", + "png 0.18.1", + "skrifa 0.42.1", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -3330,6 +3353,15 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" version = "0.4.15" @@ -3870,7 +3902,7 @@ dependencies = [ "gif", "image-webp", "num-traits", - "png", + "png 0.17.16", "qoi", "ravif", "rayon", @@ -4224,6 +4256,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + [[package]] name = "lazy-js-bundle" version = "0.7.9" @@ -4642,7 +4686,7 @@ dependencies = [ "loki-renderer", "loki-vello", "loro", - "peniko", + "peniko 0.5.0", "serde", "serde_json", "thiserror 2.0.18", @@ -4690,6 +4734,18 @@ dependencies = [ "wgpu", ] +[[package]] +name = "loki-render-cpu" +version = "0.1.0" +dependencies = [ + "image", + "loki-doc-model", + "loki-fonts", + "loki-layout", + "thiserror 2.0.18", + "vello_cpu 0.0.9", +] + [[package]] name = "loki-renderer" version = "0.1.0" @@ -4869,7 +4925,7 @@ dependencies = [ "loki-sheet-model", "loki-vello", "loro", - "peniko", + "peniko 0.5.0", "serde", "serde_json", "thiserror 2.0.18", @@ -4919,7 +4975,7 @@ dependencies = [ "loki-templates", "loki-vello", "loro", - "peniko", + "peniko 0.5.0", "serde", "serde_json", "thiserror 2.0.18", @@ -4941,7 +4997,7 @@ dependencies = [ "loki-layout", "loki-odf", "loki-ooxml", - "peniko", + "peniko 0.5.0", "pollster", "thiserror 2.0.18", "vello", @@ -6249,6 +6305,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo 0.13.1", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -6466,6 +6535,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -6497,6 +6579,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -9347,8 +9438,8 @@ dependencies = [ "bytemuck", "futures-intrusive", "log", - "peniko", - "png", + "peniko 0.5.0", + "png 0.17.16", "skrifa 0.37.0", "static_assertions", "thiserror 2.0.18", @@ -9364,14 +9455,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a235ba928b3109ad9e7696270edb09445a52ae1c7c08e6d31a19b1cdd6cbc24a" dependencies = [ "bytemuck", - "fearless_simd", + "fearless_simd 0.3.0", "hashbrown 0.15.5", "log", - "peniko", + "peniko 0.5.0", "skrifa 0.37.0", "smallvec", ] +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd 0.4.1", + "guillotiere 0.7.0", + "hashbrown 0.17.1", + "log", + "peniko 0.6.1", + "png 0.18.1", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "vello_cpu" version = "0.0.4" @@ -9379,7 +9487,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0bd1fcf9c1814f17a491e07113623d44e3ec1125a9f3401f5e047d6d326da21" dependencies = [ "bytemuck", - "vello_common", + "vello_common 0.0.4", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.0.9", ] [[package]] @@ -9389,8 +9510,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfd5e0b9fec91df34a09fbcbbed474cec68d05691b590a911c7af83c4860ae42" dependencies = [ "bytemuck", - "guillotiere", - "peniko", + "guillotiere 0.6.2", + "peniko 0.5.0", "skrifa 0.37.0", "smallvec", ] diff --git a/Cargo.toml b/Cargo.toml index d8f1c747..84465f53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","appthere-conformance","loki-bench","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server","loki-convert","loki-print","loki-headless"] +members = ["loki-doc-model","loki-sheet-model","loki-opc","loki-primitives","loki-ooxml","loki-odf","loki-layout","loki-vello","appthere-ui","loki-app-shell","loki-text","loki-spreadsheet","loki-presentation","loki-render-cache","loki-render-cpu","loki-renderer","loki-i18n","appthere-canvas","loki-fonts","loki-graphics","loki-presentation-model","loki-epub","loki-pdf","loki-acid","loki-templates","loki-spell","appthere-conformance","loki-bench","loki-model","loki-crypto","loki-server-audit","loki-server-store","loki-server-collab","loki-server-auth","loki-server-api","loki-server","loki-convert","loki-print","loki-headless"] [workspace.dependencies] # Shared across appthere-ui and any other workspace member that needs Dioxus. diff --git a/loki-render-cpu/Cargo.toml b/loki-render-cpu/Cargo.toml new file mode 100644 index 00000000..36577e93 --- /dev/null +++ b/loki-render-cpu/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "loki-render-cpu" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Deterministic in-process CPU rasterizer for loki-layout output (Spec 02 D2 conformance candidate render path)" +repository = "https://github.com/appthere/loki" + +[dependencies] +loki-layout = { path = "../loki-layout" } +# Pinned exactly: the conformance candidate render must be reproducible +# (Spec 02 D2 — fixed DPI, pinned rasterizer settings). Bump deliberately, +# together with a golden/calibration re-check. +vello_cpu = { version = "=0.0.9", features = ["text"] } +image = { version = "0.25", default-features = false, features = ["png"] } +thiserror = { workspace = true } + +[dev-dependencies] +loki-doc-model = { path = "../loki-doc-model" } +loki-fonts = { path = "../loki-fonts" } diff --git a/loki-render-cpu/src/lib.rs b/loki-render-cpu/src/lib.rs new file mode 100644 index 00000000..31cae8cc --- /dev/null +++ b/loki-render-cpu/src/lib.rs @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Deterministic in-process CPU rasterization of [`loki_layout`] output — +//! the Spec 02 **D2** conformance *candidate* render path. +//! +//! Loki's production render path is Vello on wgpu (GPU): fast, but not +//! bit-reproducible across drivers, and unusable in the GPU-less CI/agent +//! environment. This crate renders the **same positioned items the GPU path +//! paints** (`PositionedItem` from `loki-layout`) through `vello_cpu`, a pure +//! software rasterizer needing no graphics adapter — so committed goldens +//! compare against a deterministic candidate. +//! +//! Divergence containment (Spec 02 §7.1): both paths consume the identical +//! renderer-agnostic layout output; each `PositionedItem` arm here mirrors +//! its `loki-vello` twin (same origin/offset/scale math, same skip rules). +//! The differences are confined to the rasterizer itself — which is fine, +//! because fidelity is judged against the *reference application's* golden, +//! never against Loki's GPU output. +//! +//! Deliberately not rendered (editor chrome, not document content): page +//! drop shadows, cursors, selection highlights, spell squiggles' hover +//! state. TODO(conformance-render): image items paint the same grey +//! placeholder as `loki-vello`'s unresolved-image path; decoding embedded +//! images is a follow-up. + +#![forbid(unsafe_code)] + +mod paint; + +use image::RgbaImage; +use loki_layout::{LayoutRect, PaginatedLayout, PositionedItem, PositionedRect}; +use vello_cpu::{Pixmap, RenderContext, Resources}; + +/// Errors from the CPU candidate render. +#[derive(Debug, thiserror::Error)] +pub enum RenderCpuError { + /// The requested page does not exist in the layout. + #[error("page {index} out of range ({pages} pages)")] + PageOutOfRange { + /// Requested page index. + index: usize, + /// Number of pages in the layout. + pages: usize, + }, + /// The page dimensions overflow the rasterizer's u16 pixel space. + #[error("page pixel size {0}x{1} exceeds the rasterizer limit (65535)")] + PageTooLarge(u32, u32), +} + +/// White paper, matching the production painter's page background. +const PAGE_BG: loki_layout::LayoutColor = loki_layout::LayoutColor::WHITE; + +/// Renders one page of a paginated layout to an RGBA image at `dpi`. +/// +/// The geometry mirrors `loki_vello::paint_single_page`: content items are +/// content-area-local (translated by the page margins), header/footer and +/// comment items are page-local. No editor chrome (shadow/cursor) is drawn. +pub fn render_page( + layout: &PaginatedLayout, + page_index: usize, + dpi: u32, +) -> Result { + let page = layout + .pages + .get(page_index) + .ok_or(RenderCpuError::PageOutOfRange { + index: page_index, + pages: layout.pages.len(), + })?; + + let scale = dpi as f32 / 72.0; + let (page_w, page_h) = (page.page_size.width, page.page_size.height); + let px_w = (page_w * scale).ceil() as u32; + let px_h = (page_h * scale).ceil() as u32; + if px_w > u32::from(u16::MAX) || px_h > u32::from(u16::MAX) || px_w == 0 || px_h == 0 { + return Err(RenderCpuError::PageTooLarge(px_w, px_h)); + } + + let mut ctx = RenderContext::new(px_w as u16, px_h as u16); + let mut resources = Resources::new(); + + // Paper background across the full pixmap. + paint::paint_filled_rect( + &mut ctx, + &PositionedRect { + rect: LayoutRect::new(0.0, 0.0, page_w, page_h), + color: PAGE_BG, + }, + scale, + (0.0, 0.0), + ); + + // Mirrors paint_single_page's coordinate spaces. + let page_origin = (0.0, 0.0); + let content_origin = (page.margins.left, page.margins.top); + paint::paint_items( + &mut ctx, + &mut resources, + &page.content_items, + scale, + content_origin, + ); + paint::paint_items( + &mut ctx, + &mut resources, + &page.header_items, + scale, + page_origin, + ); + paint::paint_items( + &mut ctx, + &mut resources, + &page.footer_items, + scale, + page_origin, + ); + paint::paint_items( + &mut ctx, + &mut resources, + &page.comment_items, + scale, + page_origin, + ); + + ctx.flush(); + let mut pixmap = Pixmap::new(px_w as u16, px_h as u16); + ctx.render_to_pixmap(&mut resources, &mut pixmap); + + let data = pixmap.data_as_u8_slice().to_vec(); + Ok(RgbaImage::from_raw(px_w, px_h, data) + .expect("pixmap dimensions match the buffer by construction")) +} + +/// Renders every page of the layout, in page order. +pub fn render_document( + layout: &PaginatedLayout, + dpi: u32, +) -> Result, RenderCpuError> { + (0..layout.pages.len()) + .map(|i| render_page(layout, i, dpi)) + .collect() +} + +/// Reachable item count across groups — used by tests/diagnostics to assert a +/// layout actually produced paintable content. +#[must_use] +pub fn paintable_item_count(items: &[PositionedItem]) -> usize { + items + .iter() + .map(|i| match i { + PositionedItem::ClippedGroup { items, .. } => paintable_item_count(items), + PositionedItem::RotatedGroup { items, .. } => paintable_item_count(items), + _ => 1, + }) + .sum() +} diff --git a/loki-render-cpu/src/paint.rs b/loki-render-cpu/src/paint.rs new file mode 100644 index 00000000..27924912 --- /dev/null +++ b/loki-render-cpu/src/paint.rs @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Per-item painting: each arm mirrors its `loki-vello` twin (same +//! origin/offset/scale math, same skip rules), targeting +//! `vello_cpu::RenderContext` instead of `vello::Scene`. + +use loki_layout::{ + BorderEdge, DecorationKind, LayoutColor, PositionedBorderRect, PositionedDecoration, + PositionedGlyphRun, PositionedImage, PositionedItem, PositionedRect, +}; +use vello_cpu::kurbo::{Affine, BezPath, Line, Rect, Shape, Stroke}; +use vello_cpu::{RenderContext, Resources, color::AlphaColor, peniko}; + +/// The grey placeholder `loki-vello` paints for unresolved images. +const IMAGE_PLACEHOLDER: LayoutColor = LayoutColor { + r: 0.8, + g: 0.8, + b: 0.8, + a: 1.0, +}; + +fn to_color(c: &LayoutColor) -> AlphaColor { + AlphaColor::new([c.r, c.g, c.b, c.a]) +} + +/// Paints a slice of items at `(offset, scale)` — the CPU twin of +/// `loki_vello::paint_items`. +pub(crate) fn paint_items( + ctx: &mut RenderContext, + resources: &mut Resources, + items: &[PositionedItem], + scale: f32, + offset: (f32, f32), +) { + for item in items { + match item { + PositionedItem::GlyphRun(run) => paint_glyph_run(ctx, resources, run, scale, offset), + PositionedItem::FilledRect(r) | PositionedItem::HorizontalRule(r) => { + paint_filled_rect(ctx, r, scale, offset); + } + PositionedItem::BorderRect(b) => paint_border_rect(ctx, b, scale, offset), + PositionedItem::Decoration(d) => paint_decoration(ctx, d, scale, offset), + PositionedItem::Image(img) => paint_image_placeholder(ctx, img, scale, offset), + PositionedItem::ClippedGroup { clip_rect, items } => { + let mut path = BezPath::new(); + path.extend( + Rect::new( + f64::from((clip_rect.x() + offset.0) * scale), + f64::from((clip_rect.y() + offset.1) * scale), + f64::from((clip_rect.max_x() + offset.0) * scale), + f64::from((clip_rect.max_y() + offset.1) * scale), + ) + .path_elements(0.1), + ); + ctx.push_clip_layer(&path); + paint_items(ctx, resources, items, scale, offset); + ctx.pop_layer(); + } + PositionedItem::RotatedGroup { + origin, + degrees, + content_width, + content_height, + items, + } => { + // Mirrors loki-vello's rotated-cell math: rotate the locally + // painted children about the physical cell centre. + let ox = origin.x + offset.0; + let oy = origin.y + offset.1; + let cx_local = content_width / 2.0; + let cy_local = content_height / 2.0; + let (cx_physical, cy_physical) = match *degrees as i32 { + 90 | 270 => (ox + cy_local, oy + cx_local), + _ => (ox + cx_local, oy + cy_local), + }; + let transform = Affine::translate(( + f64::from(cx_physical * scale), + f64::from(cy_physical * scale), + )) * Affine::rotate(f64::from(*degrees).to_radians()) + * Affine::translate(( + f64::from(-cx_local * scale), + f64::from(-cy_local * scale), + )); + let saved = *ctx.transform(); + ctx.set_transform(transform * saved); + paint_items(ctx, resources, items, scale, (0.0, 0.0)); + ctx.set_transform(saved); + } + // `PositionedItem` is #[non_exhaustive]; ignore unknown variants + // exactly as the GPU painter does. + _ => {} + } + } +} + +/// CPU twin of `loki_vello::glyph::paint_glyph_run`: baseline-origin +/// translate, per-glyph offsets scaled, `.notdef` skipped, hinting off. +fn paint_glyph_run( + ctx: &mut RenderContext, + resources: &mut Resources, + run: &PositionedGlyphRun, + scale: f32, + offset: (f32, f32), +) { + if run.glyphs.is_empty() || run.font_data.is_empty() { + return; + } + let blob = peniko::Blob::new(run.font_data.clone()); + let font = peniko::FontData::new(blob, run.font_index); + + let saved = *ctx.transform(); + ctx.set_transform( + saved + * Affine::translate(( + f64::from((run.origin.x + offset.0) * scale), + f64::from((run.origin.y + offset.1) * scale), + )), + ); + ctx.set_paint(to_color(&run.color)); + + let glyphs = run + .glyphs + .iter() + .filter(|g| g.id != 0) + .map(|g| vello_cpu::Glyph { + id: u32::from(g.id), + x: g.x * scale, + y: g.y * scale, + }) + .collect::>(); + + // Variable fonts render at their default instance, matching the GPU + // path (its FontDataCache passes all-zero coords — the default master). + ctx.glyph_run(resources, &font) + .font_size(run.font_size * scale) + .hint(false) + .fill_glyphs(glyphs.into_iter()); + + ctx.set_transform(saved); +} + +/// CPU twin of `loki_vello::rect::paint_filled_rect`. +pub(crate) fn paint_filled_rect( + ctx: &mut RenderContext, + item: &PositionedRect, + scale: f32, + offset: (f32, f32), +) { + ctx.set_paint(to_color(&item.color)); + ctx.fill_rect(&Rect::new( + f64::from((item.rect.x() + offset.0) * scale), + f64::from((item.rect.y() + offset.1) * scale), + f64::from((item.rect.max_x() + offset.0) * scale), + f64::from((item.rect.max_y() + offset.1) * scale), + )); +} + +/// CPU twin of `loki_vello::rect::paint_border_rect`: each present edge is a +/// centred stroke along its side; `width <= 0` edges skipped. +fn paint_border_rect( + ctx: &mut RenderContext, + item: &PositionedBorderRect, + scale: f32, + offset: (f32, f32), +) { + let x0 = f64::from((item.rect.x() + offset.0) * scale); + let y0 = f64::from((item.rect.y() + offset.1) * scale); + let x1 = f64::from((item.rect.max_x() + offset.0) * scale); + let y1 = f64::from((item.rect.max_y() + offset.1) * scale); + paint_edge(ctx, item.top.as_ref(), (x0, y0), (x1, y0), scale); + paint_edge(ctx, item.right.as_ref(), (x1, y0), (x1, y1), scale); + paint_edge(ctx, item.bottom.as_ref(), (x0, y1), (x1, y1), scale); + paint_edge(ctx, item.left.as_ref(), (x0, y0), (x0, y1), scale); +} + +fn paint_edge( + ctx: &mut RenderContext, + edge: Option<&BorderEdge>, + from: (f64, f64), + to: (f64, f64), + scale: f32, +) { + let Some(edge) = edge else { return }; + if edge.width <= 0.0 { + return; + } + ctx.set_paint(to_color(&edge.color)); + ctx.set_stroke(Stroke::new(f64::from(edge.width * scale))); + let mut path = BezPath::new(); + path.extend(Line::new(from, to).path_elements(0.1)); + ctx.stroke_path(&path); +} + +/// CPU twin of `loki_vello::decor::paint_decoration`: the stroke is centred +/// on the middle of the decoration stripe; the spelling squiggle uses the +/// same wave geometry. +fn paint_decoration( + ctx: &mut RenderContext, + item: &PositionedDecoration, + scale: f32, + offset: (f32, f32), +) { + if item.width <= 0.0 || item.thickness <= 0.0 { + return; + } + ctx.set_paint(to_color(&item.color)); + ctx.set_stroke(Stroke::new(f64::from(item.thickness * scale))); + + let x = item.x + offset.0; + let y = item.y + offset.1; + let mut path = BezPath::new(); + if item.kind == DecorationKind::Spelling { + // Squiggle: half-wave quads with a 2pt period, matching loki-vello. + let amplitude = f64::from(item.thickness * scale); + let period = f64::from(2.0 * scale); + let x0 = f64::from(x * scale); + let x1 = f64::from((x + item.width) * scale); + let yc = f64::from((y + item.thickness) * scale); + path.move_to((x0, yc)); + let mut cx = x0; + let mut up = true; + while cx < x1 { + let next = (cx + period).min(x1); + let ctrl_y = if up { yc - amplitude } else { yc + amplitude }; + path.quad_to(((cx + next) / 2.0, ctrl_y), (next, yc)); + up = !up; + cx = next; + } + } else { + let yc = f64::from((y + item.thickness / 2.0) * scale); + path.extend( + Line::new( + (f64::from(x * scale), yc), + (f64::from((x + item.width) * scale), yc), + ) + .path_elements(0.1), + ); + } + ctx.stroke_path(&path); +} + +/// TODO(conformance-render): decode and draw the actual image; today this is +/// the same grey placeholder `loki-vello` paints for unresolved images, so +/// image-bearing fixtures diff on the placeholder box, not garbage. +fn paint_image_placeholder( + ctx: &mut RenderContext, + img: &PositionedImage, + scale: f32, + offset: (f32, f32), +) { + paint_filled_rect( + ctx, + &PositionedRect { + rect: img.rect, + color: IMAGE_PLACEHOLDER, + }, + scale, + offset, + ); +} diff --git a/loki-render-cpu/tests/render_smoke.rs b/loki-render-cpu/tests/render_smoke.rs new file mode 100644 index 00000000..00fb90d0 --- /dev/null +++ b/loki-render-cpu/tests/render_smoke.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Spec 02 M5 acceptance smoke tests: the `vello_cpu` candidate path renders +//! a real laid-out document **headless, with no graphics adapter**, produces +//! actual ink, and is byte-deterministic across runs. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::document::Document; +use loki_doc_model::layout::section::Section; +use loki_doc_model::style::props::char_props::CharProps; +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; +use loki_render_cpu::{paintable_item_count, render_document, render_page}; + +/// Conformance DPI (matches `appthere_conformance::CONFORMANCE_DPI`; kept +/// literal here to avoid a dependency direction from renderer to harness). +const DPI: u32 = 144; + +fn sample_document() -> Document { + let props = CharProps { + bold: Some(true), + font_name: Some("Carlito".into()), + ..Default::default() + }; + let mut d = Document::default(); + let mut s = Section::new(); + s.blocks = vec![ + Block::Heading( + 1, + NodeAttr::default(), + vec![Inline::Str("Conformance render".into())], + ), + Block::Para(vec![ + Inline::Str("Deterministic CPU rasterization via ".into()), + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(props)), + content: vec![Inline::Str("vello_cpu".into())], + attr: NodeAttr::default(), + }), + Inline::Str(".".into()), + ]), + ]; + d.sections = vec![s]; + d +} + +fn paginated(doc: &Document) -> loki_layout::PaginatedLayout { + let mut resources = FontResources::new(); + // Embed the metric-compatible faces so shaping is machine-independent. + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + match layout_document( + &mut resources, + doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("expected paginated layout, got {other:?}"), + } +} + +#[test] +fn renders_a_page_headless_with_ink() { + let layout = paginated(&sample_document()); + assert!(!layout.pages.is_empty(), "layout must produce pages"); + assert!( + paintable_item_count(&layout.pages[0].content_items) > 0, + "layout must produce paintable items" + ); + + let page = render_page(&layout, 0, DPI).expect("render must succeed with no GPU"); + // A4 at 144 dpi ≈ 1190×1684; just assert the scale relation. + let expected_w = (layout.page_size.width * DPI as f32 / 72.0).ceil() as u32; + assert_eq!(page.width(), expected_w); + + // There must be real ink: some pixels darker than the white paper. + let dark = page + .pixels() + .filter(|p| u32::from(p.0[0]) + u32::from(p.0[1]) + u32::from(p.0[2]) < 300) + .count(); + assert!( + dark > 100, + "rendered page must contain glyph ink (found {dark} dark pixels)" + ); +} + +#[test] +fn rendering_is_deterministic() { + let layout = paginated(&sample_document()); + let a = render_page(&layout, 0, DPI).expect("render A"); + let b = render_page(&layout, 0, DPI).expect("render B"); + assert_eq!( + a.as_raw(), + b.as_raw(), + "the candidate render must be byte-deterministic (Spec 02 D2)" + ); +} + +#[test] +fn out_of_range_page_errors() { + let layout = paginated(&sample_document()); + assert!(render_page(&layout, 99, DPI).is_err()); + assert_eq!( + render_document(&layout, DPI).unwrap().len(), + layout.pages.len() + ); +} From e84247783f8ec961301247a920d9936402449da0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:24:46 +0000 Subject: [PATCH 11/27] fix(conformance): split CIEDE2000 math into golden/ciede.rs (300-line ceiling) diff.rs had crossed the ceiling in the previous commit; the sRGB->Lab and CIEDE2000 functions move to their own module, re-used by diff.rs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- appthere-conformance/src/golden/ciede.rs | 107 +++++++++++++++++++++++ appthere-conformance/src/golden/diff.rs | 103 +--------------------- appthere-conformance/src/golden/mod.rs | 1 + 3 files changed, 109 insertions(+), 102 deletions(-) create mode 100644 appthere-conformance/src/golden/ciede.rs diff --git a/appthere-conformance/src/golden/ciede.rs b/appthere-conformance/src/golden/ciede.rs new file mode 100644 index 00000000..3097c8a7 --- /dev/null +++ b/appthere-conformance/src/golden/ciede.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! sRGB → CIELAB (D65) conversion and the CIEDE2000 colour difference +//! (Sharma et al. 2005 formulation), split from `diff.rs` to keep files +//! under the 300-line ceiling. Verified against the published CIEDE2000 +//! reference pairs in `diff_tests.rs`. + +/// Converts an 8-bit sRGB pixel to CIELAB (D65 white point). +pub(super) fn srgb_to_lab([r, g, b, _]: [u8; 4]) -> [f64; 3] { + fn linear(c: u8) -> f64 { + let c = f64::from(c) / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + } + let (rl, gl, bl) = (linear(r), linear(g), linear(b)); + // sRGB D65 → XYZ. + let x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl; + let y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl; + let z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl; + // XYZ → Lab with the D65 reference white. + fn f(t: f64) -> f64 { + const DELTA: f64 = 6.0 / 29.0; + if t > DELTA * DELTA * DELTA { + t.cbrt() + } else { + t / (3.0 * DELTA * DELTA) + 4.0 / 29.0 + } + } + let (xn, yn, zn) = (0.95047, 1.0, 1.08883); + let (fx, fy, fz) = (f(x / xn), f(y / yn), f(z / zn)); + [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] +} + +/// CIEDE2000 colour difference (Sharma et al. 2005 formulation). +#[allow(clippy::many_single_char_names, clippy::similar_names)] +pub(super) fn ciede2000([l1, a1, b1]: [f64; 3], [l2, a2, b2]: [f64; 3]) -> f64 { + let c1 = (a1 * a1 + b1 * b1).sqrt(); + let c2 = (a2 * a2 + b2 * b2).sqrt(); + let c_bar = (c1 + c2) / 2.0; + let c_bar7 = c_bar.powi(7); + let g = 0.5 * (1.0 - (c_bar7 / (c_bar7 + 25.0_f64.powi(7))).sqrt()); + let ap1 = (1.0 + g) * a1; + let ap2 = (1.0 + g) * a2; + let cp1 = (ap1 * ap1 + b1 * b1).sqrt(); + let cp2 = (ap2 * ap2 + b2 * b2).sqrt(); + let hp = |ap: f64, b: f64| -> f64 { + if ap == 0.0 && b == 0.0 { + 0.0 + } else { + let h = b.atan2(ap).to_degrees(); + if h < 0.0 { h + 360.0 } else { h } + } + }; + let hp1 = hp(ap1, b1); + let hp2 = hp(ap2, b2); + + let dl = l2 - l1; + let dc = cp2 - cp1; + let dhp = if cp1 * cp2 == 0.0 { + 0.0 + } else { + let d = hp2 - hp1; + if d.abs() <= 180.0 { + d + } else if d > 180.0 { + d - 360.0 + } else { + d + 360.0 + } + }; + let dh = 2.0 * (cp1 * cp2).sqrt() * (dhp.to_radians() / 2.0).sin(); + + let l_bar = (l1 + l2) / 2.0; + let cp_bar = (cp1 + cp2) / 2.0; + let hp_bar = if cp1 * cp2 == 0.0 { + hp1 + hp2 + } else { + let sum = hp1 + hp2; + let d = (hp1 - hp2).abs(); + if d <= 180.0 { + sum / 2.0 + } else if sum < 360.0 { + (sum + 360.0) / 2.0 + } else { + (sum - 360.0) / 2.0 + } + }; + + let t = 1.0 - 0.17 * (hp_bar - 30.0).to_radians().cos() + + 0.24 * (2.0 * hp_bar).to_radians().cos() + + 0.32 * (3.0 * hp_bar + 6.0).to_radians().cos() + - 0.20 * (4.0 * hp_bar - 63.0).to_radians().cos(); + let d_theta = 30.0 * (-((hp_bar - 275.0) / 25.0).powi(2)).exp(); + let cp_bar7 = cp_bar.powi(7); + let rc = 2.0 * (cp_bar7 / (cp_bar7 + 25.0_f64.powi(7))).sqrt(); + let lb50 = (l_bar - 50.0).powi(2); + let sl = 1.0 + 0.015 * lb50 / (20.0 + lb50).sqrt(); + let sc = 1.0 + 0.045 * cp_bar; + let sh = 1.0 + 0.015 * cp_bar * t; + let rt = -(2.0 * d_theta).to_radians().sin() * rc; + + ((dl / sl).powi(2) + (dc / sc).powi(2) + (dh / sh).powi(2) + rt * (dc / sc) * (dh / sh)).sqrt() +} diff --git a/appthere-conformance/src/golden/diff.rs b/appthere-conformance/src/golden/diff.rs index f68b24f2..8498052e 100644 --- a/appthere-conformance/src/golden/diff.rs +++ b/appthere-conformance/src/golden/diff.rs @@ -12,6 +12,7 @@ use image::RgbaImage; +use super::ciede::{ciede2000, srgb_to_lab}; use super::{PerceptualReport, RegionScore}; /// Window side length (pixels) for windowed SSIM. @@ -223,108 +224,6 @@ pub fn emit_heatmap( out.save(path).map_err(DiffError::Heatmap) } -// ── sRGB → CIELAB (D65) and CIEDE2000 ──────────────────────────────────────── - -/// Converts an 8-bit sRGB pixel to CIELAB (D65 white point). -fn srgb_to_lab([r, g, b, _]: [u8; 4]) -> [f64; 3] { - fn linear(c: u8) -> f64 { - let c = f64::from(c) / 255.0; - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - } - let (rl, gl, bl) = (linear(r), linear(g), linear(b)); - // sRGB D65 → XYZ. - let x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl; - let y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl; - let z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl; - // XYZ → Lab with the D65 reference white. - fn f(t: f64) -> f64 { - const DELTA: f64 = 6.0 / 29.0; - if t > DELTA * DELTA * DELTA { - t.cbrt() - } else { - t / (3.0 * DELTA * DELTA) + 4.0 / 29.0 - } - } - let (xn, yn, zn) = (0.95047, 1.0, 1.08883); - let (fx, fy, fz) = (f(x / xn), f(y / yn), f(z / zn)); - [116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)] -} - -/// CIEDE2000 colour difference (Sharma et al. 2005 formulation). -#[allow(clippy::many_single_char_names, clippy::similar_names)] -fn ciede2000([l1, a1, b1]: [f64; 3], [l2, a2, b2]: [f64; 3]) -> f64 { - let c1 = (a1 * a1 + b1 * b1).sqrt(); - let c2 = (a2 * a2 + b2 * b2).sqrt(); - let c_bar = (c1 + c2) / 2.0; - let c_bar7 = c_bar.powi(7); - let g = 0.5 * (1.0 - (c_bar7 / (c_bar7 + 25.0_f64.powi(7))).sqrt()); - let ap1 = (1.0 + g) * a1; - let ap2 = (1.0 + g) * a2; - let cp1 = (ap1 * ap1 + b1 * b1).sqrt(); - let cp2 = (ap2 * ap2 + b2 * b2).sqrt(); - let hp = |ap: f64, b: f64| -> f64 { - if ap == 0.0 && b == 0.0 { - 0.0 - } else { - let h = b.atan2(ap).to_degrees(); - if h < 0.0 { h + 360.0 } else { h } - } - }; - let hp1 = hp(ap1, b1); - let hp2 = hp(ap2, b2); - - let dl = l2 - l1; - let dc = cp2 - cp1; - let dhp = if cp1 * cp2 == 0.0 { - 0.0 - } else { - let d = hp2 - hp1; - if d.abs() <= 180.0 { - d - } else if d > 180.0 { - d - 360.0 - } else { - d + 360.0 - } - }; - let dh = 2.0 * (cp1 * cp2).sqrt() * (dhp.to_radians() / 2.0).sin(); - - let l_bar = (l1 + l2) / 2.0; - let cp_bar = (cp1 + cp2) / 2.0; - let hp_bar = if cp1 * cp2 == 0.0 { - hp1 + hp2 - } else { - let sum = hp1 + hp2; - let d = (hp1 - hp2).abs(); - if d <= 180.0 { - sum / 2.0 - } else if sum < 360.0 { - (sum + 360.0) / 2.0 - } else { - (sum - 360.0) / 2.0 - } - }; - - let t = 1.0 - 0.17 * (hp_bar - 30.0).to_radians().cos() - + 0.24 * (2.0 * hp_bar).to_radians().cos() - + 0.32 * (3.0 * hp_bar + 6.0).to_radians().cos() - - 0.20 * (4.0 * hp_bar - 63.0).to_radians().cos(); - let d_theta = 30.0 * (-((hp_bar - 275.0) / 25.0).powi(2)).exp(); - let cp_bar7 = cp_bar.powi(7); - let rc = 2.0 * (cp_bar7 / (cp_bar7 + 25.0_f64.powi(7))).sqrt(); - let lb50 = (l_bar - 50.0).powi(2); - let sl = 1.0 + 0.015 * lb50 / (20.0 + lb50).sqrt(); - let sc = 1.0 + 0.045 * cp_bar; - let sh = 1.0 + 0.015 * cp_bar * t; - let rt = -(2.0 * d_theta).to_radians().sin() * rc; - - ((dl / sl).powi(2) + (dc / sc).powi(2) + (dh / sh).powi(2) + rt * (dc / sc) * (dh / sh)).sqrt() -} - #[cfg(test)] #[path = "diff_tests.rs"] mod tests; diff --git a/appthere-conformance/src/golden/mod.rs b/appthere-conformance/src/golden/mod.rs index 1ade4e93..fd970ffe 100644 --- a/appthere-conformance/src/golden/mod.rs +++ b/appthere-conformance/src/golden/mod.rs @@ -19,6 +19,7 @@ use std::path::PathBuf; +mod ciede; pub mod diff; pub use diff::{DiffError, Tolerance, compare_pages, emit_heatmap}; From 254a72e2ccdaebdcf02ca6328351d14180da8954 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:31:50 +0000 Subject: [PATCH 12/27] feat(conformance): ODF goldens, calibration record, visual axis live (Spec 02 M4+M5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden generation (B-2/M4): committed first golden set — goldens/odt/{para-carlito,styles-tinos,para-gelasio}/page-1.png, generated by scripts/generate-odf-goldens.sh (LibreOffice 24.2 headless -> PDF -> the pinned rasterizer at CONFORMANCE_DPI), each with a GENERATION.txt (reference app, rasterizer version, date, operator). Fixtures are produced by a checked-in generator (loki-odf --example gen_conformance_fixtures) referencing metric-compatible font names directly (D4), and the same loki-fonts TTF bytes are installed for fontconfig so both engines shape identical faces. Calibration (B-3/D5): goldens/CALIBRATION.md records the measured cross-renderer noise floor (1500 regions over 3 fixtures) and derives Tolerance::calibrated() = {min_ssim 0.60, max_delta_e 10.0} from the agreeing fixtures — replacing the folklore 0.98 with data. The pass independently FOUND a real fidelity gap: para-carlito's heatmap shows cumulative advance drift per line = LibreOffice applies Carlito kern pairs, loki-layout does not yet (known fidelity gap #23), quantified at region SSIM 0.23 / dE 19.8. Visual axis (M5 acceptance): loki-render-cpu/tests/visual_golden.rs compares candidate renders against the committed goldens at the calibrated tolerance — styles-tinos and para-gelasio PASS; the para-carlito divergence is pinned as an expected-failure canary that flips when kerning lands (gate stays advisory until then, per the record). calibrate_odf example re-runs the measurement. Phase 3.6 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- Cargo.lock | 2 + .../examples/rasterize_pdf.rs | 30 ++++ .../fixtures/odt/para-carlito.odt | Bin 0 -> 1684 bytes .../fixtures/odt/para-gelasio.odt | Bin 0 -> 1678 bytes .../fixtures/odt/styles-tinos.odt | Bin 0 -> 1714 bytes appthere-conformance/goldens/CALIBRATION.md | 62 ++++++++ .../goldens/odt/para-carlito/GENERATION.txt | 5 + .../goldens/odt/para-carlito/page-1.png | Bin 0 -> 66262 bytes .../goldens/odt/para-gelasio/GENERATION.txt | 5 + .../goldens/odt/para-gelasio/page-1.png | Bin 0 -> 32993 bytes .../goldens/odt/styles-tinos/GENERATION.txt | 5 + .../goldens/odt/styles-tinos/page-1.png | Bin 0 -> 15746 bytes .../src/golden/calibration.rs | 19 +++ appthere-conformance/src/golden/diff.rs | 21 ++- appthere-conformance/src/golden/mod.rs | 2 + loki-odf/examples/gen_conformance_fixtures.rs | 111 +++++++++++++++ loki-render-cpu/Cargo.toml | 3 + loki-render-cpu/examples/calibrate_odf.rs | 133 ++++++++++++++++++ loki-render-cpu/tests/visual_golden.rs | 111 +++++++++++++++ scripts/generate-odf-goldens.sh | 55 ++++++++ 20 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 appthere-conformance/examples/rasterize_pdf.rs create mode 100644 appthere-conformance/fixtures/odt/para-carlito.odt create mode 100644 appthere-conformance/fixtures/odt/para-gelasio.odt create mode 100644 appthere-conformance/fixtures/odt/styles-tinos.odt create mode 100644 appthere-conformance/goldens/CALIBRATION.md create mode 100644 appthere-conformance/goldens/odt/para-carlito/GENERATION.txt create mode 100644 appthere-conformance/goldens/odt/para-carlito/page-1.png create mode 100644 appthere-conformance/goldens/odt/para-gelasio/GENERATION.txt create mode 100644 appthere-conformance/goldens/odt/para-gelasio/page-1.png create mode 100644 appthere-conformance/goldens/odt/styles-tinos/GENERATION.txt create mode 100644 appthere-conformance/goldens/odt/styles-tinos/page-1.png create mode 100644 appthere-conformance/src/golden/calibration.rs create mode 100644 loki-odf/examples/gen_conformance_fixtures.rs create mode 100644 loki-render-cpu/examples/calibrate_odf.rs create mode 100644 loki-render-cpu/tests/visual_golden.rs create mode 100755 scripts/generate-odf-goldens.sh diff --git a/Cargo.lock b/Cargo.lock index bd9f0553..8fde3dbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4738,10 +4738,12 @@ dependencies = [ name = "loki-render-cpu" version = "0.1.0" dependencies = [ + "appthere-conformance", "image", "loki-doc-model", "loki-fonts", "loki-layout", + "loki-odf", "thiserror 2.0.18", "vello_cpu 0.0.9", ] diff --git a/appthere-conformance/examples/rasterize_pdf.rs b/appthere-conformance/examples/rasterize_pdf.rs new file mode 100644 index 00000000..e4ec3ef8 --- /dev/null +++ b/appthere-conformance/examples/rasterize_pdf.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! CLI wrapper over the pinned PDF→PNG rasterizer (Spec 02 D3), used by +//! `scripts/generate-odf-goldens.sh` so golden generation goes through the +//! exact same stage as every other rasterization. +//! +//! Usage: `cargo run -p appthere-conformance --example rasterize_pdf -- ` + +use appthere_conformance::PdfRasterizer; + +fn main() { + let mut args = std::env::args().skip(1); + let (Some(pdf), Some(out_dir), Some(stem)) = (args.next(), args.next(), args.next()) else { + eprintln!("usage: rasterize_pdf "); + std::process::exit(2); + }; + let rasterizer = PdfRasterizer::new().expect("pdftoppm must be installed (poppler-utils)"); + let pages = rasterizer + .rasterize( + std::path::Path::new(&pdf), + std::path::Path::new(&out_dir), + &stem, + ) + .expect("rasterization failed"); + println!("{}", rasterizer.version()); + for page in pages { + println!("{}", page.display()); + } +} diff --git a/appthere-conformance/fixtures/odt/para-carlito.odt b/appthere-conformance/fixtures/odt/para-carlito.odt new file mode 100644 index 0000000000000000000000000000000000000000..9a7e041ded0fc0486b85ca97576346ff5747d15a GIT binary patch literal 1684 zcmWIWW@h1HfB;2?xMM~<>Oc+%a{zH}W^QUpWkG6UK|xMta$-qlex80=UW#6RVsU1% zUVcGpUP^v)X>Mv>iC#%+MM(hAFcGL$MFykm9aE10d74ZN45C2V*EPgZ*VE5UKQ}Qi zGcC2aM6V(@XYE8su0sX_uHWY^Iiz~0xgep7}c)a!a zsaJD$cW{;ToRd1c*XPa(t$C&0nWvjRo>Q1+ZqQiq;p_g{oU$EB75^G%?I@n{kLT-& zD#f6hwYzH=P`&+Ew%B$iBLl+{Rt5%cptqCr^GZMg1oZ6P5bvTE1%bQIMMPd}Y}|A* zAWLlJDNZ%^1OZv0C7cgdSk``s_OlDko7{WhOZA>-rPE&~hijJzUh}*l>G#OGr{!6I zM{Mekr$1zD9~}Rq*1Bm*%mt>3VD7ide7D*~S+|vyoJ!lVaTZhYrNFaU4_7=F>AAq& zop-V1$}B&wc@H;#Zxc$ATAj2(RMSGxi^s%$l7%AowV!csr%4@*R@JxKKiwu_{f;+V z;wxF=_g>soreArWjVKoN^KUdWjzN>AOKbLm-dH&rt>+ZJPJNNp_)Y}g?_T0Ss z!h7rOXWn0Z^WS|++w!h(-x%;WdraZ;f7%XtlWW|B*G5V^23aR*?bhB~Uf6YSOFW^hIfyjjAk5|m7 zEN#m)dCQix#ZJY39&1&;O1-l>_gX&F(idA-_BspHIN2=`{rw`4@v5$o&VyHSbD365 z{nz7FDX92W(In{8UPkF1Q@bz5?=n?g?fP?%`TtA%=F5AnTh4|Ji=^ID?E=p%emGsHDTY)uuA`V zs{^;}2)$GIe#@UNwM9T~@Zs5OAN9R9ntR&#hE`tg%k&580hxV84;Se!{nhQ!xI%KN z@8vhM{_^?wH5>3`#_wgTJ!gKr=3MyaW7B8no}PE)au$!Yp!|zRHgc7{0Xx$=?M2(# zuh@v!ozxFlY_2eObw^Dt+Y`p)*N@FK)wug9)gVM_W|H8<>knL&cP?t3Q29{ovF@A) zU9)4f zlDzPdSZgGUSySxt-3RtfTs>v>m#hr2TQ)(vvWkm0NqETr|Mf#2HBMBx)@+{#j0+uL zynsul)RIJSjPyJ5HG#{IrV{?CI}C-R%H}!nJKSQNeL?b)*yE2|l{nS(BF_E!{`u4F zZ!eEOYE-mRQ+@SHgG=S`wTlamysgqazvyav+wXglET_}Hd93_0W7nhDr5xT%j>vHA z=+wozOwdgIauL0$RmDLa;%G)}oJbFAyI!rF-zN$XrA!?(uVTyr=3)SE{? zt<1Ikoz_n_S|&a+;{&`InYfs7m#;t%fx(hS5Cto3LApekaTlZzJ-`yC(HF?X zTCAcQfnF#fjBo)4C{huHt`R*iAT+WuBj*fsedzIp&^HU1tAMo%IQ9a(S=m4`tU#y$ MbVwYq%w%8y0REnkV*mgE literal 0 HcmV?d00001 diff --git a/appthere-conformance/fixtures/odt/para-gelasio.odt b/appthere-conformance/fixtures/odt/para-gelasio.odt new file mode 100644 index 0000000000000000000000000000000000000000..7bf703193dc11ead127bc1ab84b407f41d5d41a0 GIT binary patch literal 1678 zcmWIWW@h1HfB;2?xMM~<>Oc+%a{zH}W^QUpWkG6UK|xMta$-qlex80=UW#6RVsU1% zUVcGpUP^v)X>Mv>iC#%+MM(hAFcGL$MFykm9aE10d74ZN45C2V*EPgZ*VE5UKQ}Qi zGcC2aM6V(@XYE8su0sX_uHWY^Iiz~0xgep7}c)a!a zsaJD$cW{;ToRd1c*XPa(t$C&0nWvjRo>Q1+ZqQiq;p_g{oU$EB75^G%?I@n{kLT-& zD#f6hwYzH=P`#bvwZUXEBLhP!3j+f;(A&xRc_p9#0(y39$l0J41D<`qMdr?HJylU? z-C$nZ_?2Ad;Bs_bYH3+pxA-iR``1C}hYAA5gkng4mW$f=+rNA3r{x?B1!GUWY4Zrr-4IqTuEEUq)2?xw3-7taeeaMIV= z<$BrhdP(pHp80#trwCk*5N%%Taj-KWX@TXWBb<>N&VA1+UnRKb>=W1L-zTxZ5uHBw zTe{rAaNDSB?^OA37&PeaZQ1+e+{zU#1`BT)=+}o{)LdyFw(<71-rL6m-#sa?m|(bz zt9E7C>6(@Qp3GjpCvvU(9R2DeQv9V3pEKr6IDI)Y=&`u(w+RM^kF%VwJDIie7Y~2( zLGHu?rDSK{Yu*pVGH#s9c(k8O^b~uK*Nk=3)2A_<4Bq4^w`aELxkh1;zpr>$pS;|m zm1Z|LQOft4L(et0Pmd!PoOk=gU$W`KtAOD4d$Vs@usEteagNxyUQRUNuJU1~b?+`} zHblKVzha8Gpi{h)vZ}rGhQ{cf5`WLH){LBeGX3_S4&$x9>qHdI>x6!qqsHCSdvkJf zfN^IEj5|=SC@!hYNi7CNU2pK|yk-M|J>Rt_yg#yZ;;kgsqSq_iy>jgjFfxZ7%)aTZ zP#^!A&DO7TA|lO1l!K&?HbiI6d^B|fk1$)*3eO|Ya<27DP1tuctkQqp>cA~K zLhls5-|}ZmZ4r^nd-=_* zzkEJ^%?3P~@q5{7&zT>uIT!x<*!0=Cr{^8HoW&z8DF5P-ja+4Kz|OQzd(pP`D>mYF zC-nmsn=8y+-BDA^_Jr~H^84#Lbyt>}Brkj< z)*8uT))c#Z_kn#AS5KM!B`ZVhmQB#Etm5KL5+3sZfBle0jT05FHQVO_<3a}*FW@pM zwImT7BmItiP2iHFsf2&(4nyInvUv{t4!0O*Uy!^c_W0vgB~CTHh;x6wfBrQ4+sosR z8WpY7RA0T);8Hn!?c#zXZ>u!VFS^>^_WPbB%jvXl9xK1h*!3uODTnuxBQhL2y7ib! z<&;&JZ4}pv-njHZP**;C%8unGjZ-em9P2u)uy&$F(mL13@U1a7*WArM_2$t}D|2ms zr}dMKmP!Bo{%hvy_yBK4CN5^&B`eTFV6dbSM8V2gkS-Bs+(jrv4+F!JMqeNkYk`Vx z1bT6VFv0~Gph$%ix<>T8fY8XmjGQyj^`XZXLf;f%t^(F4;MfcBW@Q7(umYh1&><15 GARYkf1C~1g literal 0 HcmV?d00001 diff --git a/appthere-conformance/fixtures/odt/styles-tinos.odt b/appthere-conformance/fixtures/odt/styles-tinos.odt new file mode 100644 index 0000000000000000000000000000000000000000..cc5048334ea8b99a905710e139dce9026d5ba9f2 GIT binary patch literal 1714 zcmWIWW@h1HfB;2?xMM~<>Oc+%a{zH}W^QUpWkG6UK|xMta$-qlex80=UW#6RVsU1% zUVcGpUP^v)X>Mv>iC#%+MM(hAFcGL$MFykm9aE10d74ZN45C2V*EPgZ*VE5UKQ}Qi zGcC2aM6V(@XYE8su0sX_uHWY^Iiz~0xgep7}c)a!a zsaJD$cW{;ToRd1c*XPa(t$C&0nWvjRo>Q1+ZqQiq;p_g{oU$EB75^G%?I@n{kLT-& zD#f6hwYzH=P`!OTp-*=^BLf2;8v_G3(A&xRc_p9#0(y3Dh;MPTg23MI;RbV7UwL-P zz$<^QTy;i0gQJO$pv=J;e||G9GWE58vvBVUo%e6;zUe;ue%!5z`F+oYRSl6<%#7ha z)19ue)>PJcynA4~^Hgal=dr?-k@pKyg_lh&&3|y?)Put94YkWREOV1slW~}L8_(-$ zt2{iTZT$#F9(r7A{*FRufP-#cA)kvvO^UvSthK-Ynbg znpmFqWntXiXU?^O`n&c>tmRd?EOYVRt78izg=CCWl|0<>BqQ*LwCMKw6-j*jF;}Y1=UMBX zj?cLLr+r(++m@w48@vl&cqczT<~MzLf^EPYRWU)mr)t6`A=ar1^Js0B;|L5#UY1H_AdT&ln4lsUASr`~VIj6X!GAFed z6urH{r}LT(1onK_p78$2(uuc{Sc_h-Z1>8wKfuTwb};*`s5gIn_ZcRBqz^`TYOq$~<1qoHEmg#anTu_QDI>D>Pfb&WVUL z6HyM5KH3nSIrGuf4LrhZQ7b%;Jj=P(FEwG`&9F-Ud8-4r>H(R3MGqJ0F8$T*(YQi#sqf`Cv;Ok=_%$2wWXA7ht379a zyyjf^=VQ}n=boN-2|w4nTpM>cYmy#YJZI_*W<+OOD%*PYZ4SZuB^cXdZiE!z{u zxYlf+2aF3HV7!3Kt<;i4aE$aj@-=}=m!=Z_ zsXGjXqsrzv@H^aMoP9y^lGx*qTa`G~^dipv`TqIS>~AlRKWbF8Qd52PN`p(~@U@Ez zj=ZhXJiq8_d)x1Ok}RjwzIm+tGGo`H*rgoaOOD8J?C92GDwR`KVYX3RD|+M72SHu= z>?u2zn>0?jEOV^uu)^Aj7D?+|Bg41G++1@v`_!99KdsEQ{hiiNHd-eA^ZT!vtK$Q_ z8JW14ahJY84}rmwMi2!ni$S_Xm~j`g5IqbGOB#KFOss`2x)JC_7QzS@V1ObOVCWjr t^8!Mn3Nv!fK-Y&JUkH6WfVm1-=YV4`z?&6VhB7d)0-*xXAvLTZ9sutrr^o;R literal 0 HcmV?d00001 diff --git a/appthere-conformance/goldens/CALIBRATION.md b/appthere-conformance/goldens/CALIBRATION.md new file mode 100644 index 00000000..30e8c429 --- /dev/null +++ b/appthere-conformance/goldens/CALIBRATION.md @@ -0,0 +1,62 @@ + + +# Visual-axis calibration record (Spec 02 §7.4 / D5) + +The threshold is **data, not folklore**: this record documents the measured +cross-renderer noise floor behind `Tolerance::calibrated()` +(`appthere_conformance::golden`). Update the constants only together with +this record. + +| | | +|---|---| +| **Date** | 2026-07-05 | +| **Corpus** | `fixtures/odt/{para-carlito, styles-tinos, para-gelasio}.odt` — the M4 baseline set (single-page, metric-compatible font names per D4) | +| **Golden side** | LibreOffice 24.2 headless → PDF → pinned rasterizer (`pdftoppm 24.02.0`, 144 dpi, `-aa yes -aaVector yes`), per `scripts/generate-odf-goldens.sh` | +| **Candidate side** | `loki-odf` import → `loki-layout` (bundled faces registered) → `loki-render-cpu` (`vello_cpu` =0.0.9) at `CONFORMANCE_DPI` = 144 | +| **Fonts** | The bundled `loki-fonts` faces, installed for fontconfig so both sides shape the identical bytes | +| **Method** | `cargo run -p loki-render-cpu --example calibrate_odf` — 64 px regions, windowed SSIM + mean CIEDE2000 ΔE per region, pages cropped to the common area (the two sides differ by ≤1 px of DPI rounding) | + +## Measured distributions (500 regions/fixture, 1500 total) + +| Fixture | SSIM min | SSIM p1 | SSIM p5 | ΔE max | ΔE p99 | ΔE p95 | +|---|---|---|---|---|---|---| +| `styles-tinos` | 0.6324 | 0.8894 | 1.0000 | 9.083 | 1.112 | 0.000 | +| `para-gelasio` | 0.9044 | 0.9650 | 0.9985 | 4.014 | 2.336 | 0.177 | +| `para-carlito` | **0.2348** | 0.3495 | 0.6578 | **19.797** | 16.484 | 9.631 | + +## Analysis + +- `styles-tinos` and `para-gelasio` agree well: the sub-1.0 regions are the + expected AA/hinting/subpixel noise plus small glyph-position deltas — the + genuine cross-renderer noise floor. +- `para-carlito` diverges structurally. The failure heatmap shows text + starting aligned at each line head and drifting rightward with cumulative + error until the wrap points differ — the signature of **kerning**: + LibreOffice applies Carlito's kern pairs, Loki's layout does not yet apply + kerning (**fidelity gap #23**, `docs/audit-2026-06.md`). This is a *real, + known fidelity gap that the calibration pass independently found and + quantified*, not rasterizer noise — precisely the bug class the visual + axis exists to catch. + +## Decision + +- **Calibrated thresholds** (derived from the two agreeing fixtures, with + margin): `min_ssim = 0.60` (observed min on correct fixtures 0.6324), + `max_delta_e = 10.0` (observed max 9.083). These pass the agreeing + fixtures and fail `para-carlito`-class divergence. +- **The visual gate stays advisory** (not a hard CI gate) until fidelity + gap #23 (kerning) is closed and this pass is re-run — hardening it now + would permanently red a known, tracked gap. `visual_golden.rs` in + `loki-render-cpu` asserts the two agreeing fixtures pass and pins + `para-carlito`'s failure as an expected-divergence canary that flips when + kerning lands. + +## Reproduction + +```sh +cp loki-fonts/fonts/*.ttf ~/.fonts && fc-cache +./scripts/generate-odf-goldens.sh +cargo run -p loki-render-cpu --example calibrate_odf +``` diff --git a/appthere-conformance/goldens/odt/para-carlito/GENERATION.txt b/appthere-conformance/goldens/odt/para-carlito/GENERATION.txt new file mode 100644 index 00000000..81b49120 --- /dev/null +++ b/appthere-conformance/goldens/odt/para-carlito/GENERATION.txt @@ -0,0 +1,5 @@ +fixture: para-carlito.odt +reference: LibreOffice headless (LibreOffice 24.2.7.2 420(Build:2)) +rasterizer: pdftoppm version 24.02.0 @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes +generated: 2026-07-05 +operator: scripts/generate-odf-goldens.sh (scripted; Spec 02 §7.2) diff --git a/appthere-conformance/goldens/odt/para-carlito/page-1.png b/appthere-conformance/goldens/odt/para-carlito/page-1.png new file mode 100644 index 0000000000000000000000000000000000000000..0d9731c7fa675af0153a391db862fd8fa31f5991 GIT binary patch literal 66262 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

&%FwWzO>|`Kng7G_{rE^rs_|#8(>KQXOema2 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 Quk=`|uD$4-n%SQJ1zDdpKmY&$ literal 0 HcmV?d00001 diff --git a/appthere-conformance/goldens/odt/para-gelasio/GENERATION.txt b/appthere-conformance/goldens/odt/para-gelasio/GENERATION.txt new file mode 100644 index 00000000..b113fdb0 --- /dev/null +++ b/appthere-conformance/goldens/odt/para-gelasio/GENERATION.txt @@ -0,0 +1,5 @@ +fixture: para-gelasio.odt +reference: LibreOffice headless (LibreOffice 24.2.7.2 420(Build:2)) +rasterizer: pdftoppm version 24.02.0 @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes +generated: 2026-07-05 +operator: scripts/generate-odf-goldens.sh (scripted; Spec 02 §7.2) diff --git a/appthere-conformance/goldens/odt/para-gelasio/page-1.png b/appthere-conformance/goldens/odt/para-gelasio/page-1.png new file mode 100644 index 0000000000000000000000000000000000000000..7f7c956f54c7e3d7994925b9bf8a7328fba2529e GIT binary patch literal 32993 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

x78TVjy!f(dSY{SQ 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 literal 0 HcmV?d00001 diff --git a/appthere-conformance/goldens/odt/styles-tinos/GENERATION.txt b/appthere-conformance/goldens/odt/styles-tinos/GENERATION.txt new file mode 100644 index 00000000..1aa48028 --- /dev/null +++ b/appthere-conformance/goldens/odt/styles-tinos/GENERATION.txt @@ -0,0 +1,5 @@ +fixture: styles-tinos.odt +reference: LibreOffice headless (LibreOffice 24.2.7.2 420(Build:2)) +rasterizer: pdftoppm version 24.02.0 @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes +generated: 2026-07-05 +operator: scripts/generate-odf-goldens.sh (scripted; Spec 02 §7.2) diff --git a/appthere-conformance/goldens/odt/styles-tinos/page-1.png b/appthere-conformance/goldens/odt/styles-tinos/page-1.png new file mode 100644 index 0000000000000000000000000000000000000000..8a45153e9f9abbc4a4dda6746e724692a0ef333d GIT binary patch literal 15746 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 c+r9C{6XpEMuo}Dd*avC9s^;EA<)41}9~ Self { + Self { + min_ssim: super::calibration::CALIBRATED_MIN_SSIM, + max_delta_e: super::calibration::CALIBRATED_MAX_DELTA_E, + } + } +} + /// Converts an RGBA image to a row-major luminance buffer (BT.601, 0–255). #[must_use] pub fn to_luma(img: &RgbaImage) -> Vec { diff --git a/appthere-conformance/src/golden/mod.rs b/appthere-conformance/src/golden/mod.rs index fd970ffe..af73f566 100644 --- a/appthere-conformance/src/golden/mod.rs +++ b/appthere-conformance/src/golden/mod.rs @@ -19,9 +19,11 @@ use std::path::PathBuf; +pub mod calibration; mod ciede; pub mod diff; +pub use calibration::{CALIBRATED_MAX_DELTA_E, CALIBRATED_MIN_SSIM}; pub use diff::{DiffError, Tolerance, compare_pages, emit_heatmap}; /// A perceptual score for one tiled region of a page. diff --git a/loki-odf/examples/gen_conformance_fixtures.rs b/loki-odf/examples/gen_conformance_fixtures.rs new file mode 100644 index 00000000..5a61b69e --- /dev/null +++ b/loki-odf/examples/gen_conformance_fixtures.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Generates the ODF conformance fixture set (Spec 02 §9 / M4) into +//! `appthere-conformance/fixtures/odt/`. +//! +//! Fidelity fixtures reference the **metric-compatible font names directly** +//! (Carlito, Tinos, Gelasio — Spec 02 D4), so reference-app and candidate +//! renders use the identical bundled faces and any diff is a rendering +//! difference, not a substitution disagreement. +//! +//! Run: `cargo run -p loki-odf --example gen_conformance_fixtures` + +use std::io::Cursor; + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, StyledRun}; +use loki_doc_model::document::Document; +use loki_doc_model::io::DocumentExport; +use loki_doc_model::layout::section::Section; +use loki_doc_model::style::props::char_props::CharProps; +use loki_odf::odt::export::{OdtExport, OdtExportOptions}; + +fn run(text: &str, props: CharProps) -> Inline { + Inline::StyledRun(StyledRun { + style_id: None, + direct_props: Some(Box::new(props)), + content: vec![Inline::Str(text.into())], + attr: NodeAttr::default(), + }) +} + +fn font(name: &str) -> CharProps { + CharProps { + font_name: Some(name.into()), + ..Default::default() + } +} + +fn doc(blocks: Vec) -> Document { + let mut d = Document::default(); + let mut s = Section::new(); + s.blocks = blocks; + d.sections = vec![s]; + d +} + +/// The calibration baseline set: simple single-page documents believed +/// correct in both engines (Spec 02 §7.4). +fn fixtures() -> Vec<(&'static str, Document)> { + let lorem = "The quick brown fox jumps over the lazy dog, \ + pack my box with five dozen liquor jugs. "; + vec![ + ( + "para-carlito", + doc(vec![ + Block::Para(vec![run(&lorem.repeat(3), font("Carlito"))]), + Block::Para(vec![run(&lorem.repeat(2), font("Carlito"))]), + ]), + ), + ( + "styles-tinos", + doc(vec![ + Block::Heading( + 1, + NodeAttr::default(), + vec![run("Heading in Tinos", font("Tinos"))], + ), + Block::Para(vec![ + run("Plain, ", font("Tinos")), + run( + "bold, ", + CharProps { + bold: Some(true), + ..font("Tinos") + }, + ), + run( + "and italic", + CharProps { + italic: Some(true), + ..font("Tinos") + }, + ), + run(" text.", font("Tinos")), + ]), + ]), + ), + ( + "para-gelasio", + doc(vec![Block::Para(vec![run( + &lorem.repeat(2), + font("Gelasio"), + )])]), + ), + ] +} + +fn main() { + let out_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../appthere-conformance/fixtures/odt"); + std::fs::create_dir_all(&out_dir).expect("create fixtures dir"); + for (stem, document) in fixtures() { + let mut buf = Cursor::new(Vec::new()); + OdtExport::export(&document, &mut buf, OdtExportOptions::default()).expect("export"); + let path = out_dir.join(format!("{stem}.odt")); + std::fs::write(&path, buf.into_inner()).expect("write fixture"); + println!("wrote {}", path.display()); + } +} diff --git a/loki-render-cpu/Cargo.toml b/loki-render-cpu/Cargo.toml index 36577e93..7cdae55f 100644 --- a/loki-render-cpu/Cargo.toml +++ b/loki-render-cpu/Cargo.toml @@ -18,3 +18,6 @@ thiserror = { workspace = true } [dev-dependencies] loki-doc-model = { path = "../loki-doc-model" } loki-fonts = { path = "../loki-fonts" } +# Calibration example: import the ODF fixtures and diff against goldens. +loki-odf = { path = "../loki-odf" } +appthere-conformance = { path = "../appthere-conformance" } diff --git a/loki-render-cpu/examples/calibrate_odf.rs b/loki-render-cpu/examples/calibrate_odf.rs new file mode 100644 index 00000000..eb0a20d8 --- /dev/null +++ b/loki-render-cpu/examples/calibrate_odf.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The Spec 02 §7.4 / D5 calibration pass: measures the natural +//! cross-renderer noise floor between the committed LibreOffice goldens and +//! Loki's `vello_cpu` candidate renders, over the baseline fixture set +//! believed correct in both engines. +//! +//! Prints per-fixture and aggregate region-score distributions (SSIM and +//! CIEDE2000 ΔE) — the data the committed calibration record +//! (`appthere-conformance/goldens/CALIBRATION.md`) and the calibrated +//! `Tolerance` derive from. Also emits heatmaps for visual inspection. +//! +//! Run: `cargo run -p loki-render-cpu --example calibrate_odf` + +use std::io::Cursor; +use std::path::Path; + +use appthere_conformance::CONFORMANCE_DPI; +use appthere_conformance::golden::{Tolerance, compare_pages, emit_heatmap}; +use loki_doc_model::io::DocumentImport; +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; +use loki_odf::odt::import::{OdtImport, OdtImportOptions}; +use loki_render_cpu::render_page; + +fn percentile(sorted: &[f64], p: f64) -> f64 { + if sorted.is_empty() { + return f64::NAN; + } + let idx = ((sorted.len() - 1) as f64 * p).round() as usize; + sorted[idx] +} + +fn main() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let fixtures = root.join("appthere-conformance/fixtures/odt"); + let goldens = root.join("appthere-conformance/goldens/odt"); + let scratch = std::env::temp_dir().join("loki-calibration"); + std::fs::create_dir_all(&scratch).expect("scratch dir"); + + let mut all_ssim: Vec = Vec::new(); + let mut all_de: Vec = Vec::new(); + + for entry in std::fs::read_dir(&fixtures).expect("fixtures dir") { + let path = entry.expect("entry").path(); + if path.extension().is_none_or(|e| e != "odt") { + continue; + } + let stem = path.file_stem().unwrap().to_string_lossy().to_string(); + let golden_png = goldens.join(&stem).join("page-1.png"); + if !golden_png.exists() { + eprintln!("skipping {stem}: no golden committed"); + continue; + } + + // Candidate: import → layout (bundled faces registered) → CPU render. + let bytes = std::fs::read(&path).expect("read fixture"); + let doc = OdtImport::import(Cursor::new(bytes), OdtImportOptions::default()) + .expect("fixture must import"); + let mut resources = FontResources::new(); + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + let layout = match layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("expected paginated layout, got {other:?}"), + }; + let candidate = render_page(&layout, 0, CONFORMANCE_DPI).expect("candidate render"); + let golden = image::open(&golden_png).expect("golden decodes").to_rgba8(); + + // The two sides may differ by a rounding pixel; crop to the common + // area (recorded in the calibration doc). + let w = golden.width().min(candidate.width()); + let h = golden.height().min(candidate.height()); + let golden = image::imageops::crop_imm(&golden, 0, 0, w, h).to_image(); + let candidate_c = image::imageops::crop_imm(&candidate, 0, 0, w, h).to_image(); + + // Permissive tolerance: this pass MEASURES; it does not judge. + let report = compare_pages( + &golden, + &candidate_c, + Tolerance { + min_ssim: 0.0, + max_delta_e: f64::MAX, + }, + ) + .expect("compare"); + + let mut ssim: Vec = report.regions.iter().map(|r| r.ssim).collect(); + let mut de: Vec = report.regions.iter().map(|r| r.delta_e).collect(); + ssim.sort_by(f64::total_cmp); + de.sort_by(f64::total_cmp); + println!( + "{stem}: {} regions ({}x{} px)\n ssim min={:.4} p1={:.4} p5={:.4} median={:.4}\n ΔE max={:.3} p99={:.3} p95={:.3} median={:.3}", + ssim.len(), + w, + h, + ssim.first().unwrap(), + percentile(&ssim, 0.01), + percentile(&ssim, 0.05), + percentile(&ssim, 0.50), + de.last().unwrap(), + percentile(&de, 0.99), + percentile(&de, 0.95), + percentile(&de, 0.50), + ); + let heatmap = scratch.join(format!("{stem}-heatmap.png")); + emit_heatmap(&golden, &candidate_c, &heatmap).expect("heatmap"); + println!(" heatmap: {}", heatmap.display()); + + all_ssim.extend(ssim); + all_de.extend(de); + } + + all_ssim.sort_by(f64::total_cmp); + all_de.sort_by(f64::total_cmp); + println!( + "\nAGGREGATE over {} regions:\n ssim min={:.4} p1={:.4} p5={:.4}\n ΔE max={:.3} p99={:.3} p95={:.3}", + all_ssim.len(), + all_ssim.first().unwrap(), + percentile(&all_ssim, 0.01), + percentile(&all_ssim, 0.05), + all_de.last().unwrap(), + percentile(&all_de, 0.99), + percentile(&all_de, 0.95), + ); +} diff --git a/loki-render-cpu/tests/visual_golden.rs b/loki-render-cpu/tests/visual_golden.rs new file mode 100644 index 00000000..a225b9bc --- /dev/null +++ b/loki-render-cpu/tests/visual_golden.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! The visual-goldens axis over the committed ODF golden set, at the +//! **calibrated** tolerance (Spec 02 M5 acceptance: a correct fixture +//! passes, a mis-rendered one fails, and the threshold traces to the +//! calibration record, not a literal). +//! +//! Advisory status: this suite runs in CI as ordinary tests, but the gate is +//! kept honest by pinning the *known* divergence (`para-carlito`, fidelity +//! gap #23 — kerning) as an expected failure rather than hiding it. When +//! kerning lands in `loki-layout`, that canary flips and must be promoted to +//! a passing assertion (and the calibration re-run). + +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +use appthere_conformance::CONFORMANCE_DPI; +use appthere_conformance::golden::{Tolerance, compare_pages}; +use loki_doc_model::io::DocumentImport; +use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, layout_document}; +use loki_odf::odt::import::{OdtImport, OdtImportOptions}; +use loki_render_cpu::render_page; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("..") +} + +/// Renders the fixture's first page and compares against its committed +/// golden at the calibrated tolerance. Returns the pass verdict and the +/// worst region for diagnostics. +fn compare(stem: &str) -> (bool, String) { + let fixture = root().join(format!("appthere-conformance/fixtures/odt/{stem}.odt")); + let golden_png = root().join(format!( + "appthere-conformance/goldens/odt/{stem}/page-1.png" + )); + + let bytes = std::fs::read(&fixture).expect("fixture exists (committed)"); + let doc = OdtImport::import(Cursor::new(bytes), OdtImportOptions::default()) + .expect("fixture imports"); + let mut resources = FontResources::new(); + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + let layout = match layout_document( + &mut resources, + &doc, + LayoutMode::Paginated, + 1.0, + &LayoutOptions::default(), + ) { + DocumentLayout::Paginated(p) => p, + other => panic!("expected paginated layout, got {other:?}"), + }; + let candidate = render_page(&layout, 0, CONFORMANCE_DPI).expect("candidate render"); + let golden = image::open(&golden_png) + .expect("golden decodes (committed)") + .to_rgba8(); + + // ≤1 px DPI-rounding difference between the two pipelines (recorded in + // CALIBRATION.md); crop to the common area. + let w = golden.width().min(candidate.width()); + let h = golden.height().min(candidate.height()); + let golden = image::imageops::crop_imm(&golden, 0, 0, w, h).to_image(); + let candidate = image::imageops::crop_imm(&candidate, 0, 0, w, h).to_image(); + + let report = compare_pages(&golden, &candidate, Tolerance::calibrated()).expect("compare"); + let worst = report + .worst + .map(|r| { + format!( + "worst region {:?}: ssim={:.4} delta_e={:.3}", + r.region, r.ssim, r.delta_e + ) + }) + .unwrap_or_default(); + (report.passed, worst) +} + +#[test] +fn styles_tinos_matches_its_golden() { + let (passed, worst) = compare("styles-tinos"); + assert!( + passed, + "styles-tinos must pass calibrated tolerance; {worst}" + ); +} + +#[test] +fn para_gelasio_matches_its_golden() { + let (passed, worst) = compare("para-gelasio"); + assert!( + passed, + "para-gelasio must pass calibrated tolerance; {worst}" + ); +} + +/// Expected-divergence canary: LibreOffice applies Carlito's kern pairs; +/// Loki's layout does not yet (fidelity gap #23), so lines drift and wrap +/// differently. When kerning lands this assertion FAILS — flip it to a +/// passing golden check and re-run the calibration (CALIBRATION.md). +#[test] +fn para_carlito_divergence_is_the_known_kerning_gap() { + let (passed, worst) = compare("para-carlito"); + assert!( + !passed, + "para-carlito unexpectedly passes — kerning may have landed; \ + promote this canary to a passing check and re-calibrate" + ); + eprintln!("known kerning-gap divergence (fidelity #23): {worst}"); +} diff --git a/scripts/generate-odf-goldens.sh b/scripts/generate-odf-goldens.sh new file mode 100755 index 00000000..60ca4247 --- /dev/null +++ b/scripts/generate-odf-goldens.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 AppThere Loki contributors +# +# Generates the ODF golden set (Spec 02 §7.2 / M4, scripted LibreOffice path): +# +# fixture .odt --soffice --headless--> .pdf --pinned rasterizer--> page PNGs +# +# The rasterization stage is appthere-conformance's PdfRasterizer (pdftoppm at +# the fixed CONFORMANCE_DPI with pinned AA) — identical to every other path, +# so golden and candidate differ only in the layout/render engine (D3). +# +# Requirements: soffice (libreoffice), poppler-utils, and the bundled +# metric-compatible fonts installed for fontconfig (e.g. copy +# loki-fonts/fonts/*.ttf to ~/.fonts && fc-cache) so LibreOffice shapes with +# the same faces Loki bundles (D4). +# +# Output: appthere-conformance/goldens/odt//page-N.png plus a +# GENERATION.txt metadata record (operator, LO + rasterizer versions, date). + +set -euo pipefail +cd "$(dirname "$0")/.." + +FIXTURES=appthere-conformance/fixtures/odt +GOLDENS=appthere-conformance/goldens/odt +command -v soffice >/dev/null || { echo "ERROR: soffice not found" >&2; exit 1; } + +# Regenerate fixtures from source so they always match the checked-in producer. +cargo run -q -p loki-odf --example gen_conformance_fixtures + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +mkdir -p "$GOLDENS" +LO_VERSION=$(soffice --version 2>/dev/null | head -1) + +for odt in "$FIXTURES"/*.odt; do + stem=$(basename "$odt" .odt) + echo "==> $stem" + soffice --headless --convert-to pdf --outdir "$WORK" "$odt" >/dev/null + out="$GOLDENS/$stem" + rm -rf "$out" + mkdir -p "$out" + RASTER_VERSION=$(cargo run -q -p appthere-conformance --example rasterize_pdf -- \ + "$WORK/$stem.pdf" "$out" page | head -1) + { + echo "fixture: $stem.odt" + echo "reference: LibreOffice headless ($LO_VERSION)" + echo "rasterizer: $RASTER_VERSION @ 144 dpi (CONFORMANCE_DPI), -aa yes -aaVector yes" + echo "generated: $(date -u +%Y-%m-%d)" + echo "operator: scripts/generate-odf-goldens.sh (scripted; Spec 02 §7.2)" + } > "$out/GENERATION.txt" +done + +echo "Goldens written to $GOLDENS" From e1a6efaa136d73f66b00eaaefb4021193172a4a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:34:23 +0000 Subject: [PATCH 13/27] ci(conformance): run all three Spec 02 axes in CI; Phase 3 tracking updated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-and-test installs poppler-utils alongside libxml2-utils and documents which suites form which gate: schema (M2) and round-trip (M3) hard, visual (M5) advisory-by-construction via the pinned kerning-gap canary (goldens/CALIBRATION.md). - Plan Phase 3 rows and audit §4 Spec-02 row updated: B-1..B-6, B-10, B-11 are BUILT; B-8 (Fixture/Consumer traits), B-9 (corpus reorg), OOXML manual goldens, and Strict XSDs remain the tracked tail. Phase 3.7 of docs/deferred-features-plan-2026-07-04.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- .github/workflows/rust.yml | 17 ++++++++++++----- docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 21 +++++++++++---------- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index bb359a7e..45c20987 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -58,11 +58,18 @@ jobs: - name: Install toolchain uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Install xmllint (libxml2) - # appthere-conformance's schema axis (Spec 02 M2) shells to xmllint; install - # it so those tests actually run instead of skipping. The harness fails - # loudly if it is absent, so this keeps the schema axis genuinely exercised. - run: sudo apt-get update && sudo apt-get install -y libxml2-utils + - name: Install conformance tools (xmllint, pdftoppm) + # Spec 02 gates, all running as ordinary cargo tests in this job: + # - schema axis (M2): loki-{ooxml,odf} schema_validation.rs shell to + # xmllint against the vendored ISO 29500 / ODF schemas — HARD gate. + # - round-trip axis (M3): conformance_round_trip.rs suites — HARD gate. + # - visual axis (M5): loki-render-cpu visual_golden.rs compares + # vello_cpu renders to the committed LO goldens at the calibrated + # tolerance; needs pdftoppm only for the raster wrapper's own tests. + # Advisory-by-construction: the known kerning divergence is pinned as + # an expected-failure canary (see goldens/CALIBRATION.md). + # Each harness fails loudly if its tool is absent — never skips. + run: sudo apt-get update && sudo apt-get install -y libxml2-utils poppler-utils # `--all-features` so the feature-gated format crates (pptx, xlsx) and their # integration tests actually compile and run — under the default feature set # `cargo test --workspace` silently skips `#![cfg(feature = ...)]` suites. diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 14426c53..3114256a 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -116,7 +116,7 @@ Verified against code; none silently done-since except the Spec-02 "resolved-as- | Spec | Deferred item | Verified | |---|---|---| | 01 | `clippy::pedantic` lint set + allow-list; AST-level `no_hardcoded_layout_dims` dylint; `cargo udeps` dead-`pub` sweep; `editor_save` typed `SaveError`; Android target build verification; 300-line backlog | STILL-OPEN (deliberate residuals) | -| 02 | `vello_cpu` render path (B-1); vendored ISO 29500/ODF schemas (B-6); zero committed goldens (B-2); uncalibrated SSIM 0.98 (B-3); ΔE/worst-region/heatmap differ (B-4); pinned PDF→PNG rasterizer (B-5); shared `Fixture`/`Consumer` traits (B-8); corpus reorg + ODP/ODG/PPTX importers (B-9); **bundle Gelasio (B-10)**; CI gate wiring (B-11) | STILL-OPEN (several "resolved-as-decision" only) | +| 02 | ~~`vello_cpu` path, schemas, goldens, calibration, differ, rasterizer, Gelasio, CI wiring~~ **BUILT 2026-07-05** (plan Phase 3): B-1 (`loki-render-cpu`), B-2 (3 ODF goldens + generation script), B-3 (`CALIBRATION.md` → `Tolerance::calibrated()`; the pass quantified fidelity gap #23/kerning, pinned as a canary), B-4 (SSIM+ΔE worst-region differ + heatmaps), B-5 (`PdfRasterizer`), B-6 (ISO 29500/OPC/ODF/MathML3 vendored + real-export validation), B-10 (Gelasio + substitution suite), B-11 (axes live in CI). Remaining: B-8 `Fixture`/`Consumer` traits, B-9 corpus reorg, OOXML manual goldens, Strict XSDs. | LARGELY BUILT | | 03 | Metadata-panel label stacking <250px (R-13g); responsive doc type-scale (M4); real `Viewport.zoom`; ribbon tab-strip touch height (handed to Spec 04) | STILL-OPEN | | 04 | **M3 width-driven collapse cascade** (condensed/overflow menu, priority, hysteresis) — unbuilt; **M5 Layout/References/Review tabs + `selected_object` contextual signal** — unbuilt (only 3 non-contextual tabs); M6 touch posture; cursor-into-new-cell after insert | STILL-OPEN (Spec 04 is the least-complete "shipped" spec) | | 05 | **Page** family (`page_styles` catalog, ADR-0012); **Table** family (`TableProps` conditional/banding regions); character-style **editing form**; per-family non-paragraph `Default` sources; Compact tree breadcrumb (M7) | STILL-OPEN (model-gated) | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 0d216bce..27dbd201 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -82,16 +82,17 @@ BM-3** (render-cost proxy) and the ACID headless-raster registry item. | Task | Spec item | Detail | Effort | |---|---|---|---| -| 3.1 | B-10 | Bundle the Gelasio font face into `loki-fonts/fonts/` (license-check first, as with the existing Croscore/Crosextra faces). | S | -| 3.2 | B-6 | Vendor the ISO 29500 / ODF RELAX NG schemas into `appthere-conformance/schemas/` and wire schema validation. | M | -| 3.3 | B-1 | `vello_cpu` render path in the conformance crate (no GPU in CI). Prerequisite for 3.4–3.6 and Spec 06 BM-3. | M | -| 3.4 | B-5 | Pin a PDF→PNG rasterizer (version-locked) for golden comparison. | S | -| 3.5 | B-2, B-3, B-4 | Commit the first visual goldens; calibrate the SSIM threshold empirically (replace the uncalibrated 0.98); add ΔE / worst-region / heatmap differ. | M | -| 3.6 | B-11 | Wire the conformance gate into CI (advisory first, then required once goldens are stable). | S | -| 3.7 | B-8, B-9 | Shared `Fixture`/`Consumer` traits; corpus reorg. ODP/ODG/PPTX importers stay gated on the unbuilt ACID PPTX generator (ratified decision §5.1) — keep deferred, but record it in the conformance README. | M | - -**Exit criterion**: CI runs a golden-image conformance pass on every PR; -Spec 02's tracker rows flip from "decided" to "built". +| 3.1 | B-10 | ✅ **Done 2026-07-05** — Gelasio ×4 faces bundled (OFL, full coverage reconstructed from fontsource subsets), Georgia→Gelasio mapping, dedicated substitution suite (`loki-layout/tests/font_substitution_suite.rs`). | S | +| 3.2 | B-6 | ✅ **Done 2026-07-05** — ISO 29500-4:2016 Transitional + mce, ECMA-376 Part-2 OPC, OASIS ODF 1.3 RNG + MathML3 vendored with PROVENANCE (sha256); real DOCX/ODT exports schema-validated incl. malformed-part negative tests. Tails: Strict XSDs, Dublin Core imports for core.xml (documented in `schemas/README.md`). | M | +| 3.3 | B-1 | ✅ **Done 2026-07-05** — new `loki-render-cpu` crate: `vello_cpu` (=0.0.9) rasterizes the same `PositionedItem` stream the GPU path paints, headless, byte-deterministic (M5 acceptance smoke tests). TODO(conformance-render): decode image items (grey placeholder today). | M | +| 3.4 | B-5 | ✅ **Done 2026-07-05** — `appthere_conformance::raster::PdfRasterizer` (pdftoppm pinned flags @ `CONFORMANCE_DPI` 144, version captured, byte-determinism tested). | S | +| 3.5 | B-2, B-3, B-4 | ✅ **Done 2026-07-05** — SSIM+CIEDE2000 worst-region differ with heatmaps (`golden/diff.rs`, Sharma reference pairs verified); 3 ODF goldens committed with GENERATION metadata (`scripts/generate-odf-goldens.sh`); calibration record `goldens/CALIBRATION.md` → `Tolerance::calibrated()` {0.60, 10.0}. **The calibration pass found and quantified fidelity gap #23 (kerning)** — pinned as the `para-carlito` expected-failure canary. OOXML goldens remain the manual Windows/Word COM procedure (§7.2). | M | +| 3.6 | B-11 | ✅ **Done 2026-07-05** — all three axes run as cargo tests in the existing `build-and-test` job (xmllint + pdftoppm installed); schema + round-trip are hard gates, visual is advisory-by-construction (known divergence pinned; hardens when kerning lands + recalibration). | S | +| 3.7 | B-8, B-9 | **Deferred** — shared `Fixture`/`Consumer` trait extraction from `loki-acid` and the 141-case corpus reorg remain open (the new fixtures/goldens live under `appthere-conformance/{fixtures,goldens}` as the seed of that layout). ODP/ODG/PPTX importers stay gated on the unbuilt ACID PPTX generator (§5.1). | M | + +**Exit criterion**: ✅ met for the built scope — CI runs schema, round-trip, +and visual-golden passes on every PR; Spec 02 rows B-1…B-6, B-10, B-11 are +*built*, B-7 was already largely done, B-8/B-9 remain the tracked tail. --- From 31e5fd87846ac82f83461a54c4e1d22820ac1208 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:26:31 +0000 Subject: [PATCH 14/27] feat(layout): honour CharProps.kerning with reference-matching default (fidelity gap #23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Spec 02 calibration pass flagged para-carlito diverging from its LibreOffice golden (region SSIM 0.23, dE 19.8) and initially blamed missing kerning. Root-causing inverted that: Parley 0.10's harfrust shaper already applies GPOS kern pairs (verified against Carlito's tables — the historical gap had been silently closed by the Parley 0.8->0.10 upgrade), and per-line ink measurement showed the GOLDEN consistently wider by exactly the kern amount. LibreOffice was not kerning the document at all: the fixture ODT carries no style:letter-kerning, and both LO-on-import and Word (w:kern threshold 0) default pair kerning OFF — while loki kerned unconditionally, flipping borderline line wraps into whole-line diffs. The real gap #23 was therefore the missing kerning toggle: - StyleSpan gains a `kerning: Option` field; resolve.rs forwards CharProps.kerning (already mapped by both readers: OOXML w:kern>0, ODF style:letter-kerning). Unless a run explicitly enables kerning, layout pushes a `"kern" 0` font-feature so the shaper matches the reference apps' default-off behaviour. - Regression locks in loki-layout/tests/kerning_applied.rs: kern-on advance matches the font's (A,V) pair to 0.05pt, default keeps natural advances, pairs stay contextual, ligatures unaffected. - Recalibration: para-carlito region floor improved SSIM 0.23->0.69, dE 19.8->7.5 — all three fixtures now in one noise band and GREEN at the calibrated tolerance; the expected-failure canary is promoted to a passing golden check. CALIBRATION.md records the re-measured distributions and keeps the full mis-attribution/root-cause trail as a worked example. para-carlito's fixture text also de-knife-edged (varied sentences instead of a repeated pangram). - To stay under para.rs's ratcheted ceiling, the self-contained list-marker synthesis block moved to loki-layout/src/list_marker.rs (para.rs 1979 -> 1856, baseline ratcheted down; existing para::format_counter / format_list_marker paths re-exported). - fidelity-status.md Kerning row -> resolved; audit/plan rows updated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- .../fixtures/odt/para-carlito.odt | Bin 1684 -> 1840 bytes appthere-conformance/goldens/CALIBRATION.md | 67 ++++--- .../goldens/odt/para-carlito/page-1.png | Bin 66262 -> 50052 bytes docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 2 +- docs/fidelity-status.md | 2 +- loki-layout/src/lib.rs | 1 + loki-layout/src/list_marker.rs | 152 +++++++++++++++ loki-layout/src/para.rs | 173 +++--------------- loki-layout/src/para_band_tests.rs | 1 + loki-layout/src/para_cache.rs | 1 + loki-layout/src/para_tests.rs | 2 + loki-layout/src/resolve.rs | 6 +- loki-layout/src/result_tests.rs | 1 + loki-layout/tests/kerning_applied.rs | 127 +++++++++++++ loki-odf/examples/gen_conformance_fixtures.rs | 16 +- loki-render-cpu/examples/calibrate_odf.rs | 9 + loki-render-cpu/tests/visual_golden.rs | 26 ++- loki-renderer/src/render_layout_tests.rs | 1 + loki-text/src/editing/hit_test_tests.rs | 2 + loki-text/src/editing/navigation.rs | 2 + loki-text/src/editing/reflow_nav.rs | 1 + scripts/file-ceiling-baseline.txt | 8 +- 23 files changed, 403 insertions(+), 199 deletions(-) create mode 100644 loki-layout/src/list_marker.rs create mode 100644 loki-layout/tests/kerning_applied.rs diff --git a/appthere-conformance/fixtures/odt/para-carlito.odt b/appthere-conformance/fixtures/odt/para-carlito.odt index 9a7e041ded0fc0486b85ca97576346ff5747d15a..c1af3ea80151e69f6b1e735f227d2862a9e90136 100644 GIT binary patch delta 664 zcmV;J0%!e{4X_TdQUQO5peU0y0ssJS1pojG0001EZ*FvDZgehqZETHH(XN{?41M2M zSUeB1>b|U~=Pd!o9tfCy@3<@pvmsD zP4OiIw2EqN+kN)?{b&9)d#yh0sA;sqvL-e3Xk)I(Mz+9c8_Iv#yUzx1OOio{(#jq~ z8C6LR?Sd-rqsuARd{V1@*2U;bA>80iLB18W5@;~Ng4h%rvBZXWF{@Ke!S|UWp5t0@ zihaP!!}`oq@I#GWj%&dwmK$&nZ)h#}98BI2cc^zV>i<}rjkd>|*yA`Br z$$Jy})UKBK)P8@+ckS}sXuV|BGDmWV)N{1Trw)Z`H+O3K!n{ri_u2aoW)k&mCj$O{c@B?G+-k?;G%b&!Pva{4(D3#QP;K^mxa z)1Zf`fr*fgAX}+zV9SKqhl%h^t$^=3L~T`sfawijk3rkUF2E7d0dmJLFfl)Plt-wM ztJ5becH&efPjy|p)zW?Abo}Vlch##7)sg8k3!ncx<~tM%SirB2<$#fVoiswrBVbst zG8L$KRA`oHL}&?E9Tqt$lk}!$S{@9k;}hQEVF1L#Y=|e%W%U=c1_R*&35TF4lQjYW y0B@7<1xf*;lRE}CA~pp80BvP-VJ>)WY*0%D1^@s600jUA03ZPX0Qv<00002;I6HU% delta 486 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 literal 66262 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

&%FwWzO>|`Kng7G_{rE^rs_|#8(>KQXOema2 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 Quk=`|uD$4-n%SQJ1zDdpKmY&$ diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 3114256a..736732aa 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -140,7 +140,7 @@ Still-open after verification (the DONE-SINCE ones moved to §1). | audit-2026-06 T-2/T-3/T-5 tails | ODT export impl; per-case DOCX/XLSX round-trips; hard PPTX cases | STILL-OPEN | | fidelity gap #12 | External-URL images → grey placeholder (`loki-vello/src/image.rs:34`) | STILL-OPEN | | fidelity gap #19 | RTL/bidi direction not forwarded (no Parley bidi API) | STILL-OPEN | -| fidelity gaps #23,#25,#26,#27,#29,#30 | kerning, orphan/widow, `border_between`, DocxSettings, content controls, language tags | STILL-OPEN | +| fidelity gaps #23,#25,#26,#27,#29,#30 | ~~kerning~~ (**#23 FIXED 2026-07-05** — `StyleSpan.kerning` + reference-matching off default, found by the Spec 02 calibration pass), orphan/widow, `border_between`, DocxSettings, content controls, language tags | STILL-OPEN (except #23) | | fidelity-status registry | even/odd blank pages; unequal column widths; column height balancing; drop-cap editor fallback; PDF font subsetting; PDF ICC/CMYK; PDF clip/rotate paint; EPUB drops math/fields/comments; ODT `style:default-style`; macOS symbol-bullet fallback; reflow selection-delete + touch select; ACID headless raster + ODP/ODG importers + PPTX fixture; Calc/Slides squiggle rendering; personal-dictionary persistence | STILL-OPEN (registry-tracked) | > **F1–F7** (audit-2026-06-10 app-layer: presentation tab-switch edit loss, no-op delete/copy, dead retier channels, no Save-As) were **not individually re-driven** this pass — they are app-layer and echoed in the MVP-scope doc §6; treat as likely-open pending a focused check. diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 27dbd201..f9be3cfb 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -151,7 +151,7 @@ the Watch list). Every task here must update `docs/fidelity-status.md`. | 5.6 | gap #12 / `floating-image` | External-URL images render a grey placeholder (`loki-vello/src/image.rs:34`) + detect "floating" class for inline images (`resolve.rs:705`). | M | | 5.7 | `odf-master-page` | ODF master-page transitions (`odf/reader/styles.rs:200`); pairs with the `style:default-style` registry row. | M | | 5.8 | `omml` | OMML↔MathML: delimiters, n-ary, matrices, accents (`docx/omml/mod.rs:20`). | L | -| 5.9 | gaps #23–#30 tail | Kerning, orphan/widow control, `border_between`, DocxSettings, content controls, language tags — schedule individually from the fidelity registry; orphan/widow is the highest-value (visible in any multi-page doc). | L (aggregate) | +| 5.9 | gaps #23–#30 tail | ~~Kerning~~ (✅ **#23 done 2026-07-05**: root-caused by the Phase 3 calibration pass — loki kerned unconditionally while Word/LO default off; `CharProps.kerning` now drives a shaper feature toggle with reference-matching default, regression-locked, all three visual goldens green), orphan/widow control, `border_between`, DocxSettings, content controls, language tags — schedule individually from the fidelity registry; orphan/widow is the highest-value (visible in any multi-page doc). | L (aggregate) | | 5.10 | registry | Page/column geometry set: even/odd blank pages, unequal column widths, column height balancing; PDF font subsetting + ICC/CMYK; EPUB math/fields/comments. | L (aggregate) | | 5.11 | `link-click` | Interactive hyperlink hit-testing (visual hint only today) — spans layout (`resolve.rs:689`, `items.rs:125`, `para.rs:203`) and renderer (`scene.rs:519`). | M | diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index fc472022..f90cb50f 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -45,7 +45,7 @@ This is the living source of truth documenting which document features, characte | **Small Caps / All Caps** | Yes | Yes | Yes | Both are **synthesized** during `flatten_paragraph` (resolve.rs), since Parley exposes no `FontVariantCaps`. **All caps:** the run text is uppercased. **Small caps:** the text is uppercased *and* the letters that were lowercase in the source are split into their own spans at a reduced size (`SMALL_CAPS_RATIO` = 0.8 of the cap size), so capitals stay full height while former-lowercase letters render as small capitals — the real small-caps look. (Previously `small_caps` only set an unused `StyleSpan.font_variant` flag, so small-caps text rendered at full size, indistinguishable from normal.) Tested by `flatten_all_caps_uppercases_text` and `flatten_small_caps_uppercases_and_shrinks_lowercase`. **DOCX export** now writes `w:caps` for all-caps direct run properties (`emit_char_props`; small-caps `w:smallCaps` was already emitted); previously all-caps was dropped on DOCX export (ODT export already round-tripped it). Surfaced by the Spec 02 conformance harness. | | **Shadow Text** | Yes | Yes | Yes | Mapped to StyleSpan properties. | | **Horizontal Scale (`w:w`)** | Yes | Yes | Yes | `CharProps.scale` reaches `StyleSpan.scale` and is applied geometrically to glyph advances/positions and highlight/decoration widths in `emit_glyph_run`, anchored at the run's left edge; later runs on the line are shifted by the added width (now returned from `emit_glyph_run`) so they do not overlap. **Applied per glyph** via each glyph's cluster→span mapping, so a scaled run that Parley shaped together with an un-scaled neighbour (same font/colour) still stretches only its own glyphs — see the baseline-shift row for the coalescing rationale. COMPAT(parley): Parley has no geometric horizontal scale, so line-breaking still measures the unscaled run — a scaled run can extend past the right margin where Word would have wrapped earlier (gap #14). Tested by `horizontal_scale_widens_glyph_advances` and `coalesced_scale_and_baseline_shift_apply_per_glyph`. **DOCX export** now writes `w:w` (integer percent) for a direct run property (`emit_char_props`); previously DOCX export dropped it (ODT export already round-tripped it). The same `emit_char_props` pass also restores DOCX export of `w:shadow`, `w:kern`, `w:szCs`, the complex/East-Asian `w:rFonts` faces, run `w:shd` background, and `w:lang` — every direct run property the importer reads is now written back, keeping DOCX export symmetric with import. Surfaced by the Spec 02 conformance harness. | -| **Kerning** | Yes | No | No | Kerning flag dropped at layout time (gap #23). | +| **Kerning** | Yes | Yes | Yes | **Resolved 2026-07-05** (gap #23). `CharProps.kerning` (OOXML `w:kern > 0`, ODF `style:letter-kerning`) reaches `StyleSpan.kerning` and toggles the shaper's GPOS `kern` feature. Default is **off**, matching the reference apps (Word's `w:kern` threshold defaults to 0; LibreOffice treats an ODT without the property as off) — the shaper (Parley 0.10/harfrust) would otherwise kern everything, which made loki's lines ~0.5 % narrower than reference renders of never-kerned documents and flipped borderline wraps (root-caused by the Spec 02 calibration pass; see `appthere-conformance/goldens/CALIBRATION.md`). Regression-locked by `loki-layout/tests/kerning_applied.rs` (kern-on advance, default-off natural advance, contextual pairs, ligatures unaffected). | | **Language Tags** | Yes | No | No | No locale-sensitive shaping or hyphenation. | --- diff --git a/loki-layout/src/lib.rs b/loki-layout/src/lib.rs index fd5d38bc..f3531835 100644 --- a/loki-layout/src/lib.rs +++ b/loki-layout/src/lib.rs @@ -30,6 +30,7 @@ pub mod font; pub mod geometry; pub mod incremental; pub mod items; +mod list_marker; mod math; pub mod mode; pub mod para; diff --git a/loki-layout/src/list_marker.rs b/loki-layout/src/list_marker.rs new file mode 100644 index 00000000..e26ba2b7 --- /dev/null +++ b/loki-layout/src/list_marker.rs @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! List-marker synthesis: bullet / numbering label formatting. +//! +//! Split from `para.rs` (300-line-ceiling backlog, Q-1) — self-contained +//! formatting helpers with no layout-state dependencies. + +use loki_doc_model::style::list_style::{BulletChar, ListLevel, ListLevelKind, NumberingScheme}; + +// ── List marker synthesis ───────────────────────────────────────────────────── + +/// Produce the display string for a list marker at `level` in `list_levels`. +/// +/// Handles bullet characters, all six [`NumberingScheme`] variants, and +/// multi-level `%N`-style format strings (OOXML `w:lvlText`, ODF +/// `text:num-format`). Picture bullets fall back to `"•"`. +/// +/// # Arguments +/// * `list_levels` – all level definitions for the list (from `ListStyle.levels`) +/// * `level` – the zero-based level being rendered +/// * `counters` – current per-level counter array (all 9 levels) +/// +/// Returns an empty string for `ListLevelKind::None`. +pub fn format_list_marker(list_levels: &[ListLevel], level: u8, counters: &[u32; 9]) -> String { + let Some(level_def) = list_levels.get(level as usize) else { + return String::new(); + }; + match &level_def.kind { + ListLevelKind::Bullet { + char: BulletChar::Char(c), + .. + } => c.to_string(), + ListLevelKind::Bullet { + char: BulletChar::Image, + .. + } => { + // TODO(list-picture-bullet): picture bullets not yet supported; render as • + "•".to_string() + } + ListLevelKind::Numbered { format, .. } => { + format_numbered_label(list_levels, format, counters) + } + ListLevelKind::None => String::new(), + // Non-exhaustive guard. + _ => String::new(), + } +} + +/// Expand a `w:lvlText`-style format string, replacing `%N` tokens with +/// the counter at 0-based level N-1 formatted by that level's scheme. +fn format_numbered_label(list_levels: &[ListLevel], format: &str, counters: &[u32; 9]) -> String { + let mut result = String::with_capacity(format.len() + 4); + let mut chars = format.chars().peekable(); + while let Some(c) = chars.next() { + if c == '%' + && let Some(&d) = chars.peek() + && d.is_ascii_digit() + && d != '0' + { + chars.next(); + let level_idx = (d as u8 - b'1') as usize; // 1-based → 0-based + let counter = counters.get(level_idx).copied().unwrap_or(1); + let scheme = list_levels + .get(level_idx) + .map(|l| match &l.kind { + ListLevelKind::Numbered { scheme, .. } => *scheme, + _ => NumberingScheme::Decimal, + }) + .unwrap_or(NumberingScheme::Decimal); + result.push_str(&format_counter(counter, scheme)); + continue; + } + result.push(c); + } + result +} + +/// Format a single counter value according to its numbering scheme. +/// +/// Shared by list-marker rendering and page-number fields (OOXML +/// `w:pgNumType @w:fmt`). +pub(crate) fn format_counter(n: u32, scheme: NumberingScheme) -> String { + match scheme { + NumberingScheme::Decimal => n.to_string(), + NumberingScheme::LowerAlpha => alpha_label(n, false), + NumberingScheme::UpperAlpha => alpha_label(n, true), + NumberingScheme::LowerRoman => roman_numeral(n, false), + NumberingScheme::UpperRoman => roman_numeral(n, true), + NumberingScheme::Ordinal => format!("{}{}", n, ordinal_suffix(n)), + NumberingScheme::None => String::new(), + _ => n.to_string(), // non-exhaustive fallback + } +} + +/// Convert `n` to an alphabetic label: 1→a, 2→b, …, 26→z, 27→aa, 28→ab, … +fn alpha_label(mut n: u32, upper: bool) -> String { + let mut buf = Vec::new(); + while n > 0 { + n -= 1; + let byte = b'a' + (n % 26) as u8; + buf.push(if upper { + byte.to_ascii_uppercase() + } else { + byte + }); + n /= 26; + } + buf.reverse(); + String::from_utf8(buf).unwrap_or_default() +} + +/// Convert `n` to a Roman numeral string. +fn roman_numeral(n: u32, upper: bool) -> String { + const TABLE: &[(u32, &str)] = &[ + (1000, "m"), + (900, "cm"), + (500, "d"), + (400, "cd"), + (100, "c"), + (90, "xc"), + (50, "l"), + (40, "xl"), + (10, "x"), + (9, "ix"), + (5, "v"), + (4, "iv"), + (1, "i"), + ]; + let mut n = n; + let mut s = String::new(); + for &(val, sym) in TABLE { + while n >= val { + s.push_str(sym); + n -= val; + } + } + if upper { s.to_uppercase() } else { s } +} + +/// Return the English ordinal suffix for `n` (1st, 2nd, 3rd, …, 11th, …). +fn ordinal_suffix(n: u32) -> &'static str { + match n % 100 { + 11..=13 => "th", + _ => match n % 10 { + 1 => "st", + 2 => "nd", + 3 => "rd", + _ => "th", + }, + } +} diff --git a/loki-layout/src/para.rs b/loki-layout/src/para.rs index b03dd0fe..f0fe3059 100644 --- a/loki-layout/src/para.rs +++ b/loki-layout/src/para.rs @@ -12,14 +12,12 @@ use std::ops::Range; use std::sync::Arc; -use loki_doc_model::style::list_style::{ - BulletChar, ListId, ListLevel, ListLevelKind, NumberingScheme, -}; +use loki_doc_model::style::list_style::ListId; use loki_doc_model::style::props::tab_stop::{TabAlignment, TabLeader}; use parley::{ - Alignment, AlignmentOptions, Cursor, FontFamily, FontStyle, FontWeight, InlineBox, - InlineBoxKind, LineHeight, OverflowWrap, PositionedLayoutItem, RangedBuilder, Selection, - StyleProperty, + Alignment, AlignmentOptions, Cursor, FontFamily, FontFeatures, FontStyle, FontWeight, + InlineBox, InlineBoxKind, LineHeight, OverflowWrap, PositionedLayoutItem, RangedBuilder, + Selection, StyleProperty, }; use crate::color::LayoutColor; @@ -219,6 +217,13 @@ pub struct StyleSpan { /// margin where Word would have wrapped earlier. pub scale: Option, + /// Apply GPOS pair kerning to this run (gap #23). `Some(true)` = kern; + /// anything else = off, matching the reference apps' defaults (Word's + /// `w:kern` threshold defaults to 0 = off; LibreOffice treats an ODT + /// without `style:letter-kerning` as off). The shaper (harfrust) defaults + /// kerning ON, so the off case is an explicit feature disable. + pub kerning: Option, + /// Manual baseline shift (text rise) in points; positive raises the glyphs /// above the baseline, negative lowers them. `None` = on the baseline. /// OOXML `w:position`; ODF `style:text-position`. @@ -915,6 +920,15 @@ pub(crate) fn push_para_styles( if let Some(ws) = span.word_spacing { builder.push(StyleProperty::WordSpacing(ws), r.clone()); } + // Kerning (gap #23): the reference apps default pair kerning OFF + // (see StyleSpan::kerning); harfrust defaults it ON — disable unless + // the document explicitly enables it. + if span.kerning != Some(true) { + builder.push( + StyleProperty::FontFeatures(FontFeatures::from("\"kern\" 0")), + r.clone(), + ); + } // Caps variant (gaps #15, #16): SmallCaps stored but not applied (no Parley API). // AllCaps: text was already uppercased during flatten_paragraph. } @@ -1765,148 +1779,11 @@ fn layout_paragraph_uncached( } } -// ── List marker synthesis ───────────────────────────────────────────────────── - -/// Produce the display string for a list marker at `level` in `list_levels`. -/// -/// Handles bullet characters, all six [`NumberingScheme`] variants, and -/// multi-level `%N`-style format strings (OOXML `w:lvlText`, ODF -/// `text:num-format`). Picture bullets fall back to `"•"`. -/// -/// # Arguments -/// * `list_levels` – all level definitions for the list (from `ListStyle.levels`) -/// * `level` – the zero-based level being rendered -/// * `counters` – current per-level counter array (all 9 levels) -/// -/// Returns an empty string for `ListLevelKind::None`. -pub fn format_list_marker(list_levels: &[ListLevel], level: u8, counters: &[u32; 9]) -> String { - let Some(level_def) = list_levels.get(level as usize) else { - return String::new(); - }; - match &level_def.kind { - ListLevelKind::Bullet { - char: BulletChar::Char(c), - .. - } => c.to_string(), - ListLevelKind::Bullet { - char: BulletChar::Image, - .. - } => { - // TODO(list-picture-bullet): picture bullets not yet supported; render as • - "•".to_string() - } - ListLevelKind::Numbered { format, .. } => { - format_numbered_label(list_levels, format, counters) - } - ListLevelKind::None => String::new(), - // Non-exhaustive guard. - _ => String::new(), - } -} - -/// Expand a `w:lvlText`-style format string, replacing `%N` tokens with -/// the counter at 0-based level N-1 formatted by that level's scheme. -fn format_numbered_label(list_levels: &[ListLevel], format: &str, counters: &[u32; 9]) -> String { - let mut result = String::with_capacity(format.len() + 4); - let mut chars = format.chars().peekable(); - while let Some(c) = chars.next() { - if c == '%' - && let Some(&d) = chars.peek() - && d.is_ascii_digit() - && d != '0' - { - chars.next(); - let level_idx = (d as u8 - b'1') as usize; // 1-based → 0-based - let counter = counters.get(level_idx).copied().unwrap_or(1); - let scheme = list_levels - .get(level_idx) - .map(|l| match &l.kind { - ListLevelKind::Numbered { scheme, .. } => *scheme, - _ => NumberingScheme::Decimal, - }) - .unwrap_or(NumberingScheme::Decimal); - result.push_str(&format_counter(counter, scheme)); - continue; - } - result.push(c); - } - result -} - -/// Format a single counter value according to its numbering scheme. -/// -/// Shared by list-marker rendering and page-number fields (OOXML -/// `w:pgNumType @w:fmt`). -pub(crate) fn format_counter(n: u32, scheme: NumberingScheme) -> String { - match scheme { - NumberingScheme::Decimal => n.to_string(), - NumberingScheme::LowerAlpha => alpha_label(n, false), - NumberingScheme::UpperAlpha => alpha_label(n, true), - NumberingScheme::LowerRoman => roman_numeral(n, false), - NumberingScheme::UpperRoman => roman_numeral(n, true), - NumberingScheme::Ordinal => format!("{}{}", n, ordinal_suffix(n)), - NumberingScheme::None => String::new(), - _ => n.to_string(), // non-exhaustive fallback - } -} - -/// Convert `n` to an alphabetic label: 1→a, 2→b, …, 26→z, 27→aa, 28→ab, … -fn alpha_label(mut n: u32, upper: bool) -> String { - let mut buf = Vec::new(); - while n > 0 { - n -= 1; - let byte = b'a' + (n % 26) as u8; - buf.push(if upper { - byte.to_ascii_uppercase() - } else { - byte - }); - n /= 26; - } - buf.reverse(); - String::from_utf8(buf).unwrap_or_default() -} - -/// Convert `n` to a Roman numeral string. -fn roman_numeral(n: u32, upper: bool) -> String { - const TABLE: &[(u32, &str)] = &[ - (1000, "m"), - (900, "cm"), - (500, "d"), - (400, "cd"), - (100, "c"), - (90, "xc"), - (50, "l"), - (40, "xl"), - (10, "x"), - (9, "ix"), - (5, "v"), - (4, "iv"), - (1, "i"), - ]; - let mut n = n; - let mut s = String::new(); - for &(val, sym) in TABLE { - while n >= val { - s.push_str(sym); - n -= val; - } - } - if upper { s.to_uppercase() } else { s } -} - -/// Return the English ordinal suffix for `n` (1st, 2nd, 3rd, …, 11th, …). -fn ordinal_suffix(n: u32) -> &'static str { - match n % 100 { - 11..=13 => "th", - _ => match n % 10 { - 1 => "st", - 2 => "nd", - 3 => "rd", - _ => "th", - }, - } -} +// List-marker synthesis lives in `crate::list_marker` (split from this +// file); re-exported here so `para::format_counter` callers and the +// `para_tests.rs` suite keep their existing paths. +pub(crate) use crate::list_marker::format_counter; +pub use crate::list_marker::format_list_marker; // ── Private helpers for span → glyph-run lookups ────────────────────────────── diff --git a/loki-layout/src/para_band_tests.rs b/loki-layout/src/para_band_tests.rs index 63477e43..e2749ba4 100644 --- a/loki-layout/src/para_band_tests.rs +++ b/loki-layout/src/para_band_tests.rs @@ -37,6 +37,7 @@ fn span(text: &str) -> StyleSpan { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, } } diff --git a/loki-layout/src/para_cache.rs b/loki-layout/src/para_cache.rs index 6f2a2751..fb551486 100644 --- a/loki-layout/src/para_cache.rs +++ b/loki-layout/src/para_cache.rs @@ -160,6 +160,7 @@ mod tests { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, } } diff --git a/loki-layout/src/para_tests.rs b/loki-layout/src/para_tests.rs index 8b8ef295..6f324e3c 100644 --- a/loki-layout/src/para_tests.rs +++ b/loki-layout/src/para_tests.rs @@ -48,6 +48,7 @@ fn single_span(text: &str, font_size: f32) -> StyleSpan { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, } } @@ -668,6 +669,7 @@ fn coalesced_scale_and_baseline_shift_apply_per_glyph() { let mk = |range: std::ops::Range, scale: Option, rise: Option| StyleSpan { range, scale, + kerning: None, baseline_shift: rise, ..single_span("A", 20.0) }; diff --git a/loki-layout/src/resolve.rs b/loki-layout/src/resolve.rs index 5e838ddc..29d9fc7e 100644 --- a/loki-layout/src/resolve.rs +++ b/loki-layout/src/resolve.rs @@ -304,13 +304,16 @@ fn effective_run_char_props( // shadow → StyleSpan.shadow (gap #24, P3) // scale → StyleSpan.scale (gap #14, P2) // +// kerning → StyleSpan.kerning (gap #23, P3 — +// applied as a shaper feature toggle; default OFF to +// match Word/LO defaults) +// // Fields SILENTLY DROPPED (out of scope for Group 1): // font_name_complex — complex-script font (BiDi) // font_name_east_asian — East Asian font // font_size_complex — complex-script font size // background_color — per-run background (distinct from highlight) // outline — hollow text effect -// kerning — kerning flag (gap #23, P3) // language / language_complex / language_east_asian — locale (gap #30, P3) // hyperlink — URL (gap #11, P1 — handled at Inline level) @@ -382,6 +385,7 @@ fn char_props_to_style_span(props: &CharProps, range: Range) -> StyleSpan font_variant, word_spacing: props.word_spacing.map(pts_to_f32), // gap #22 shadow: props.shadow.unwrap_or(false), // gap #24 + kerning: props.kerning, // gap #23 link_url: None, // set by walk_inlines when inside Inline::Link (gap #11) math: None, // set by walk_inlines for Inline::Math placeholders // Horizontal text scale (gap #14): only forward a non-trivial, positive diff --git a/loki-layout/src/result_tests.rs b/loki-layout/src/result_tests.rs index 7f0982c6..fa88b72a 100644 --- a/loki-layout/src/result_tests.rs +++ b/loki-layout/src/result_tests.rs @@ -53,6 +53,7 @@ fn para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagraphData link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), diff --git a/loki-layout/tests/kerning_applied.rs b/loki-layout/tests/kerning_applied.rs new file mode 100644 index 00000000..6a21923a --- /dev/null +++ b/loki-layout/tests/kerning_applied.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Regression locks for shaping features (fidelity gap #23). +//! +//! Kerning was listed as an open fidelity gap from the swash-shaped Parley +//! era; the Parley 0.10 upgrade (harfrust shaper) closed it silently, which +//! the 2026-07-05 conformance calibration initially mis-attributed. These +//! tests pin the *verified-current* behaviour — GPOS pair kerning and +//! standard ligatures are applied — so a future Parley upgrade or a shaping +//! feature change cannot silently regress them again. +//! +//! Ground truth (from Carlito-Regular's tables, upem 2048): +//! - `A` advance 1185 units = 13.886 pt @ 24 pt +//! - kern pair (A,V) = −89 units = −1.043 pt @ 24 pt +//! - `f`+`i` (625+470) vs the `fi` ligature (1084 units) + +use loki_layout::{ + FontResources, LayoutColor, PositionedItem, ResolvedParaProps, StyleSpan, layout_paragraph, +}; + +fn carlito_span(text: &str, font_size: f32, kerning: Option) -> StyleSpan { + StyleSpan { + range: 0..text.len(), + font_name: Some("Carlito".into()), + font_size, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning, + baseline_shift: None, + } +} + +/// Lays out `text` in Carlito and returns the first glyph run's advances. +fn advances(text: &str, font_size: f32, kerning: Option) -> Vec { + let mut resources = FontResources::new(); + for blob in loki_fonts::fallback_font_blobs() { + resources.register_font(blob.to_vec()); + } + let para = layout_paragraph( + &mut resources, + text, + &[carlito_span(text, font_size, kerning)], + &ResolvedParaProps::default(), + 1000.0, + 1.0, + false, + ); + for item in ¶.items { + if let PositionedItem::GlyphRun(run) = item { + return run.glyphs.iter().map(|g| g.advance).collect(); + } + } + panic!("no glyph run produced for {text:?}"); +} + +/// With kerning enabled, "AV"'s A carries the (A,V) kern: its effective +/// advance is the natural advance (13.886 pt @ 24 pt) minus 1.043 pt. +#[test] +fn gpos_pair_kerning_is_applied_when_enabled() { + let adv = advances("AV", 24.0, Some(true)); + assert_eq!(adv.len(), 2, "AV must shape to two glyphs"); + let natural_a = 1185.0 * 24.0 / 2048.0; // 13.886 + let kerned_a = (1185.0 - 89.0) * 24.0 / 2048.0; // 12.844 + assert!( + (adv[0] - kerned_a).abs() < 0.05, + "A before V must carry the (A,V) kern pair: expected ≈{kerned_a:.3}, got {} \ + (unkerned would be {natural_a:.3} — if you see that, shaping lost kerning)", + adv[0] + ); +} + +/// The default (no explicit kerning property) must NOT kern — matching the +/// reference apps: Word's `w:kern` threshold defaults to 0 (off) and +/// LibreOffice treats an ODT without `style:letter-kerning` as off. This is +/// what keeps loki's line widths aligned with reference renders of documents +/// that never asked for kerning (see goldens/CALIBRATION.md). +#[test] +fn kerning_defaults_off_like_the_reference_apps() { + let adv = advances("AV", 24.0, None); + let natural_a = 1185.0 * 24.0 / 2048.0; + assert!( + (adv[0] - natural_a).abs() < 0.05, + "A must keep its natural advance {natural_a:.3} when kerning is not \ + requested, got {}", + adv[0] + ); +} + +/// The kern must be contextual, not a blanket advance change: a bare "A" +/// keeps its natural advance. +#[test] +fn kerning_is_contextual() { + let adv = advances("AH", 24.0, Some(true)); // no (A,H) kern pair in Carlito + let natural_a = 1185.0 * 24.0 / 2048.0; + assert!( + (adv[0] - natural_a).abs() < 0.05, + "A before H must keep its natural advance {natural_a:.3}, got {}", + adv[0] + ); +} + +/// Standard ligatures must be applied: "five" shapes to 3 glyphs (fi + v + e). +#[test] +fn standard_ligatures_are_applied() { + let adv = advances("five", 24.0, None); + assert_eq!( + adv.len(), + 3, + "'five' must shape with the fi ligature (3 glyphs), got {} glyphs", + adv.len() + ); +} diff --git a/loki-odf/examples/gen_conformance_fixtures.rs b/loki-odf/examples/gen_conformance_fixtures.rs index 5a61b69e..3c4266ef 100644 --- a/loki-odf/examples/gen_conformance_fixtures.rs +++ b/loki-odf/examples/gen_conformance_fixtures.rs @@ -49,14 +49,26 @@ fn doc(blocks: Vec) -> Document { /// The calibration baseline set: simple single-page documents believed /// correct in both engines (Spec 02 §7.4). fn fixtures() -> Vec<(&'static str, Document)> { + // Varied sentences, not one repeated pangram: repeating an identical + // sentence manufactures many near-identical wrap candidates, so a + // sub-pixel cross-renderer advance delta (the ~0.3%/line noise floor, + // see goldens/CALIBRATION.md) can flip a borderline line break and + // cascade into a whole-line diff. Visual-axis fixtures must wrap + // decisively. + let carlito_p1 = "Conformance fixtures exercise the rendering pipeline end to end. \ + Each paragraph flows through import, layout, and rasterization before \ + the perceptual differ compares it against a reference render. \ + Distinct sentence lengths keep every line break decisive."; + let carlito_p2 = "A second paragraph checks inter-paragraph spacing. \ + Short words then follow: it is so, and we go on to the end of the block."; let lorem = "The quick brown fox jumps over the lazy dog, \ pack my box with five dozen liquor jugs. "; vec![ ( "para-carlito", doc(vec![ - Block::Para(vec![run(&lorem.repeat(3), font("Carlito"))]), - Block::Para(vec![run(&lorem.repeat(2), font("Carlito"))]), + Block::Para(vec![run(carlito_p1, font("Carlito"))]), + Block::Para(vec![run(carlito_p2, font("Carlito"))]), ]), ), ( diff --git a/loki-render-cpu/examples/calibrate_odf.rs b/loki-render-cpu/examples/calibrate_odf.rs index eb0a20d8..dc045570 100644 --- a/loki-render-cpu/examples/calibrate_odf.rs +++ b/loki-render-cpu/examples/calibrate_odf.rs @@ -113,6 +113,15 @@ fn main() { let heatmap = scratch.join(format!("{stem}-heatmap.png")); emit_heatmap(&golden, &candidate_c, &heatmap).expect("heatmap"); println!(" heatmap: {}", heatmap.display()); + // Optional side-by-side dump for offline inspection of a divergence. + if let Ok(dir) = std::env::var("CALIBRATE_DUMP_DIR") { + let dir = Path::new(&dir); + std::fs::create_dir_all(dir).expect("dump dir"); + golden.save(dir.join(format!("{stem}-golden.png"))).ok(); + candidate_c + .save(dir.join(format!("{stem}-candidate.png"))) + .ok(); + } all_ssim.extend(ssim); all_de.extend(de); diff --git a/loki-render-cpu/tests/visual_golden.rs b/loki-render-cpu/tests/visual_golden.rs index a225b9bc..a52bfb9d 100644 --- a/loki-render-cpu/tests/visual_golden.rs +++ b/loki-render-cpu/tests/visual_golden.rs @@ -6,11 +6,12 @@ //! passes, a mis-rendered one fails, and the threshold traces to the //! calibration record, not a literal). //! -//! Advisory status: this suite runs in CI as ordinary tests, but the gate is -//! kept honest by pinning the *known* divergence (`para-carlito`, fidelity -//! gap #23 — kerning) as an expected failure rather than hiding it. When -//! kerning lands in `loki-layout`, that canary flips and must be promoted to -//! a passing assertion (and the calibration re-run). +//! All three committed fixtures pass at the calibrated tolerance. The +//! original `para-carlito` divergence was root-caused (2026-07-05) to loki +//! kerning unconditionally while the reference apps default kerning OFF; +//! `StyleSpan::kerning` now honours the document property with a +//! reference-matching default (see `loki-layout/tests/kerning_applied.rs` +//! and goldens/CALIBRATION.md). use std::io::Cursor; use std::path::{Path, PathBuf}; @@ -95,17 +96,14 @@ fn para_gelasio_matches_its_golden() { ); } -/// Expected-divergence canary: LibreOffice applies Carlito's kern pairs; -/// Loki's layout does not yet (fidelity gap #23), so lines drift and wrap -/// differently. When kerning lands this assertion FAILS — flip it to a -/// passing golden check and re-run the calibration (CALIBRATION.md). +/// Formerly the kerning-gap canary: this fixture diverged while loki kerned +/// text the reference apps leave unkerned (gap #23, resolved 2026-07-05). +/// Now a full member of the passing golden set. #[test] -fn para_carlito_divergence_is_the_known_kerning_gap() { +fn para_carlito_matches_its_golden() { let (passed, worst) = compare("para-carlito"); assert!( - !passed, - "para-carlito unexpectedly passes — kerning may have landed; \ - promote this canary to a passing check and re-calibrate" + passed, + "para-carlito must pass calibrated tolerance; {worst}" ); - eprintln!("known kerning-gap divergence (fidelity #23): {worst}"); } diff --git a/loki-renderer/src/render_layout_tests.rs b/loki-renderer/src/render_layout_tests.rs index e25569cb..7c7a119b 100644 --- a/loki-renderer/src/render_layout_tests.rs +++ b/loki-renderer/src/render_layout_tests.rs @@ -67,6 +67,7 @@ fn one_para_reflow(text: &str, origin: (f32, f32)) -> RenderLayout { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), diff --git a/loki-text/src/editing/hit_test_tests.rs b/loki-text/src/editing/hit_test_tests.rs index 949a6bc8..7e03294f 100644 --- a/loki-text/src/editing/hit_test_tests.rs +++ b/loki-text/src/editing/hit_test_tests.rs @@ -37,6 +37,7 @@ fn make_test_layout() -> PaginatedLayout { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), @@ -310,6 +311,7 @@ fn reflow_para(text: &str, block_index: usize, origin: (f32, f32)) -> PageParagr link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), diff --git a/loki-text/src/editing/navigation.rs b/loki-text/src/editing/navigation.rs index f4a46e91..04afa59b 100644 --- a/loki-text/src/editing/navigation.rs +++ b/loki-text/src/editing/navigation.rs @@ -343,6 +343,7 @@ mod tests { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), @@ -494,6 +495,7 @@ mod tests { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }; let para0 = layout_paragraph( diff --git a/loki-text/src/editing/reflow_nav.rs b/loki-text/src/editing/reflow_nav.rs index c3f81e30..e01c136b 100644 --- a/loki-text/src/editing/reflow_nav.rs +++ b/loki-text/src/editing/reflow_nav.rs @@ -180,6 +180,7 @@ mod tests { link_url: None, math: None, scale: None, + kerning: None, baseline_shift: None, }], &ResolvedParaProps::default(), diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index aa93d811..2fc7ab79 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -3,21 +3,21 @@ # these may not GROW, and must be removed once split to <= 300 lines. # Regenerate with: scripts/check-file-ceiling.py --update -1979 loki-layout/src/para.rs 1953 loki-layout/src/flow.rs +1856 loki-layout/src/para.rs 1554 loki-odf/src/odt/reader/styles.rs 1494 loki-odf/src/odt/reader/document.rs 1244 loki-spreadsheet/src/routes/editor/editor_inner.rs 1209 loki-ooxml/src/docx/reader/document.rs 1073 loki-ooxml/src/docx/write/document.rs -974 loki-layout/src/resolve.rs +978 loki-layout/src/resolve.rs 948 loki-vello/src/scene.rs 870 loki-text/src/routes/editor/editor_inner.rs 741 loki-layout/src/flow_para.rs 730 loki-ooxml/src/docx/mapper/inline.rs 651 loki-odf/src/package.rs 611 loki-ooxml/src/docx/mapper/document.rs -591 loki-text/src/editing/navigation.rs +593 loki-text/src/editing/navigation.rs 582 loki-ooxml/src/docx/mapper/props.rs 575 loki-ooxml/src/xlsx/import.rs 491 loki-text/src/routes/editor/editor_canvas.rs @@ -29,7 +29,7 @@ 373 loki-layout/src/incremental.rs 365 loki-odf/src/ods/export.rs 364 loki-text/src/routes/home.rs -333 loki-layout/src/lib.rs +334 loki-layout/src/lib.rs 331 loki-text/src/editing/state.rs 328 loki-ooxml/src/docx/model/paragraph.rs 316 loki-text/src/routes/editor/editor_style.rs From 4dbada304522be0567d67413804d0fb2da37eb79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 17:47:37 +0000 Subject: [PATCH 15/27] Selection-aware editing: typing replaces, Backspace/Delete remove (F6c) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b.7a of the deferred-features plan (audit F6c): the editor previously ignored an active range selection — typing inserted at the focus and Backspace deleted a single grapheme, leaving the selected text in place. Model layer (loki-doc-model): - New loro_mutation::selection module with delete_selection_at(): removes the text between two (BlockPath, byte) endpoints in either order, collapsing any blocks the range spans by composing the existing merge_block_at + delete_text_at primitives. The surviving paragraph keeps the first block's style (Word behaviour). - The whole range is validated BEFORE the first mutation: endpoints in different containers (body/cell/note, or different sections) return the new MutationError::CrossContainerSelection, and a non-text block inside the range (e.g. a table) returns TextNotFound — nothing is half-applied in either case. - 10 integration tests (loro_selection_delete_tests.rs): single-block, three-block collapse, both endpoint orders, inside a table cell, and the no-mutation rejection cases. Editor wiring (loki-text): - New editor_keydown_text.rs hosting the Character and Backspace arms (moved from editor_keydown.rs, which was at 288/300 lines) plus the shared selection-removal helper; Delete falls back to the existing grapheme handler only when no selection is active. - Replace-typing batches the range delete and the insert into one relayout + commit, so it is a single undo entry. - A selection the model rejects swallows the key instead of falling through to a single-cursor edit. - 7 unit tests via the #[path] sibling-file idiom. Docs: audit F6 row + plan 4b.7 updated (clipboard and off-thread I/O remain open); fidelity-status reflow row no longer lists selection-delete as missing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 4 +- docs/deferred-features-plan-2026-07-04.md | 2 +- docs/fidelity-status.md | 2 +- loki-doc-model/src/lib.rs | 4 +- loki-doc-model/src/loro_mutation/mod.rs | 7 + loki-doc-model/src/loro_mutation/selection.rs | 128 +++++++++ .../tests/loro_selection_delete_tests.rs | 219 +++++++++++++++ loki-text/src/routes/editor/editor_keydown.rs | 148 +++------- .../src/routes/editor/editor_keydown_text.rs | 255 ++++++++++++++++++ .../editor/editor_keydown_text_tests.rs | 113 ++++++++ loki-text/src/routes/editor/mod.rs | 1 + 11 files changed, 767 insertions(+), 116 deletions(-) create mode 100644 loki-doc-model/src/loro_mutation/selection.rs create mode 100644 loki-doc-model/tests/loro_selection_delete_tests.rs create mode 100644 loki-text/src/routes/editor/editor_keydown_text.rs create mode 100644 loki-text/src/routes/editor/editor_keydown_text_tests.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 736732aa..7618b57b 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -141,7 +141,7 @@ Still-open after verification (the DONE-SINCE ones moved to §1). | fidelity gap #12 | External-URL images → grey placeholder (`loki-vello/src/image.rs:34`) | STILL-OPEN | | fidelity gap #19 | RTL/bidi direction not forwarded (no Parley bidi API) | STILL-OPEN | | fidelity gaps #23,#25,#26,#27,#29,#30 | ~~kerning~~ (**#23 FIXED 2026-07-05** — `StyleSpan.kerning` + reference-matching off default, found by the Spec 02 calibration pass), orphan/widow, `border_between`, DocxSettings, content controls, language tags | STILL-OPEN (except #23) | -| fidelity-status registry | even/odd blank pages; unequal column widths; column height balancing; drop-cap editor fallback; PDF font subsetting; PDF ICC/CMYK; PDF clip/rotate paint; EPUB drops math/fields/comments; ODT `style:default-style`; macOS symbol-bullet fallback; reflow selection-delete + touch select; ACID headless raster + ODP/ODG importers + PPTX fixture; Calc/Slides squiggle rendering; personal-dictionary persistence | STILL-OPEN (registry-tracked) | +| fidelity-status registry | even/odd blank pages; unequal column widths; column height balancing; drop-cap editor fallback; PDF font subsetting; PDF ICC/CMYK; PDF clip/rotate paint; EPUB drops math/fields/comments; ODT `style:default-style`; macOS symbol-bullet fallback; reflow touch select (selection-delete resolved 2026-07-05); ACID headless raster + ODP/ODG importers + PPTX fixture; Calc/Slides squiggle rendering; personal-dictionary persistence | STILL-OPEN (registry-tracked) | > **F1–F7** (audit-2026-06-10 app-layer: presentation tab-switch edit loss, no-op delete/copy, dead retier channels, no Save-As) were **not individually re-driven** this pass — they are app-layer and echoed in the MVP-scope doc §6; treat as likely-open pending a focused check. @@ -197,7 +197,7 @@ per-sub-item verdicts against HEAD `20b05a6`: | F3 | Edits lost on tab switch; dirty flag never set | **LARGELY RESOLVED** | Per-tab retention via `loki-text/src/sessions.rs` `DocSession` (stash/restore in `editor_path_sync.rs:42-154`); dirty tracks a generation baseline (`editor_inner.rs:465-476`), cleared on save. **Residual (F3c):** `shell.rs:101-145` still closes dirty tabs with no confirmation, discarding the stashed session. | | F4 | Untitled documents cannot be saved; no Ctrl+S | **RESOLVED** | Save As via `pick_file_to_save` (`editor_inner.rs:484-535`); Ctrl/Cmd+S bound (`editor_keydown.rs:60-67`) routing untitled → Save As. | | F5 | Settle/retier pipeline wired to dead channels | **RESOLVED (by removal)** | The pipeline was deleted, not fixed: virtualization now bounds memory by mounting only viewport-window pages (`virtualize.rs` `visible_window`, `document_view.rs:290`). The 06-10 audit's §5 claim of "downsample by viewport distance" was wrong about the mechanism — corrected there. **Residual:** `DocumentViewProps::eq` still hardwired `false` (`document_view.rs:143-147`) — now a benign over-render, capped by `PageTile`'s own `PartialEq`. | -| F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom controls dead in all 3 apps; spreadsheet ribbon tab-select/collapse dead**. STILL-OPEN: F6a hooks in conditionals/loops (`recent_files.rs:45,96,219`), F6c typing/Backspace ignore selection + no clipboard, F6f synchronous save/load on UI thread. | +| F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom controls dead in all 3 apps; spreadsheet ribbon tab-select/collapse dead**. RESOLVED 2026-07-05: F6c-selection — typing replaces the active selection and Backspace/Delete remove it, including multi-block ranges (`loki_doc_model::delete_selection_at` composes `merge_block_at` + `delete_text_at` with pre-validation, so cross-container or table-spanning ranges are rejected untouched; wired via `editor_keydown_text.rs`; tested by `loro_selection_delete_tests.rs` + editor unit tests). STILL-OPEN: F6a hooks in conditionals/loops (`recent_files.rs:45,96,219`), F6c-clipboard (copy/cut/paste — partially gated on the unimplemented dioxus-native-dom clipboard converter), F6f synchronous save/load on UI thread. | | F7 | Low grab-bag | **PARTIAL** | RESOLVED: F7d safe-area insets (RwLock + reactive version signal + resize sensor). PARTIAL: F7c — loki-text page number now live; word count still empty everywhere. STILL-OPEN: F7a `AtHomeTab` responsive layout (`viewport_width` fixed 375.0, never adopted `use_breakpoint()`), F7b index-based list keys + `active_slide_idx` not adjusted on delete-before-active, F7e debug leftovers in vendored patches, F7f `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`). | **Two stale in-code comments found during verification were fixed in the same diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index f9be3cfb..a5a551db 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -122,7 +122,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4b.4 | `nested-nav` | Sibling-path navigation inside cell/note bodies (`navigation.rs:138,174`). | S | | 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | | 4b.6 | F3c + F1 residual (audit §9) | Dirty-work protection: confirm-before-close for dirty tabs in loki-text (`shell.rs:101-145` discards the stashed session silently) and save-prompt/retention on presentation tab switch (`editor_inner.rs:50-57`). | S–M | -| 4b.7 | F6c + F6f (audit §9) | Selection-aware typing/Backspace (replace active selection), clipboard (copy/cut/paste), and move save/load I/O off the UI thread (`editor_ribbon.rs:93`, `editor_load.rs:56-101`). | M | +| 4b.7 | F6c + F6f (audit §9) | **Selection editing ✅ Done 2026-07-05:** typing replaces the active selection, Backspace/Delete remove it, incl. multi-block ranges — `loki_doc_model::delete_selection_at` (merge-then-delete composition, whole range pre-validated so cross-container / table-spanning selections are rejected untouched), editor wiring in `editor_keydown_text.rs` (replace-typing is one undo entry); tests: `loro_selection_delete_tests.rs` (10) + editor unit tests (7). **Remaining:** clipboard copy/cut/paste (partially gated on the unimplemented dioxus-native-dom clipboard converter), and moving save/load I/O off the UI thread (`editor_ribbon.rs:93`, `editor_load.rs:56-101`). | M | ### 4c. Shell/UX polish TODOs (§2) — batchable diff --git a/docs/fidelity-status.md b/docs/fidelity-status.md index f90cb50f..d4b743f4 100644 --- a/docs/fidelity-status.md +++ b/docs/fidelity-status.md @@ -24,7 +24,7 @@ This is the living source of truth documenting which document features, characte | **Math (equations)** | Yes | Partial | Yes | Mathematical equations round-trip through both formats. The format-neutral model stores math as a single MathML `` string in `Inline::Math` (the W3C interchange standard and ODF's native form). **DOCX** converts bidirectionally between OMML (`m:oMath` inline / `m:oMathPara` display) and MathML in `loki-ooxml`'s `docx/omml` module; the converter is mutually inverse over the common construct set — text runs (`m:r`/`m:t` ⇄ ``/``/``), fractions (`m:f`), super/subscripts (`m:sSup`/`m:sSub`/`m:sSubSup`), and radicals (`m:rad` ⇄ ``/``). **ODT** embeds the MathML as a formula sub-document (`draw:frame`/`draw:object` → `Object N/content.xml`, listed in the manifest with the `…opendocument.formula` media type) and reads it back, canonicalising on import (`loki-odf`'s `odt::math`); ODF does not distinguish display from inline math, so embedded formulas map to `MathType::InlineMath`. **Persisted through the Loro CRDT** losslessly: a block containing math is preserved as an opaque snapshot. **Rendered** by a first-pass math typesetter (`loki-layout`'s `math` module): the MathML is laid out into positioned glyph runs (tokens shaped via Parley) plus fraction-bar/radical rules, then placed inline via a Parley inline box (the same mechanism as tab stops). Covers identifiers/numbers/operators, rows, fractions (`mfrac`), scripts (`msup`/`msub`/`msubsup`), radicals (`msqrt`/`mroot`), and fenced expressions (`mfenced` / fence-wrapped rows) — reusing the standard `PositionedItem` glyph/rect types so `loki-vello` paints it with no renderer change. **The equation's baseline is aligned to the text baseline** (the inline box reserves the equation's ascent; Parley aligns box bottoms to the baseline, so the descent hangs below into the line like inline text, and the paragraph height grows to cover a deep denominator). **Radical signs and delimiters stretch to their content** via uniform glyph scaling. **Math is set in a serif/math face** (`FontFamily::Source("Cambria Math, STIX Two Math, Latin Modern Math, serif")` in the token shaper) rather than the sans-serif body default, matching Word's Cambria Math. **A paragraph that is solely an equation is centered** (display math): Word renders both `m:oMathPara` and a bare paragraph-level `m:oMath` as a centered display equation, so the DOCX mapper centers a paragraph whose only content (ignoring whitespace) is math when it has no explicit alignment. Tested by `omml_tests` + `math_round_trip.rs` (DOCX), `math_tests` + `math_round_trip.rs` (ODT), the `math::tests` typesetter unit tests (incl. stretch), and `inline_math_emits_typeset_items` / `inline_math_baseline_aligns_with_text` (layout integration). **Approximations / not yet done:** stretchy glyphs widen as they grow (uniform scaling, not true extensible glyphs); inter-atom spacing follows simple proportional gaps rather than the full TeX `mathspacing` table; matrices/n-ary operators/accents are not laid out. The OMML↔MathML converter likewise does not yet cover delimiters, n-ary operators, matrices, or accents (these pass through best-effort), so delimiter rendering currently applies to MathML that already contains fences (e.g. ODT-imported or hand-authored), not DOCX OMML round-trips. | | **Templates (DOTX / OTT)** | Yes | — | Partial | Office `.dotx`/`.dotm` and LibreOffice `.ott` open as new untitled documents (the importers key off the `officeDocument` relationship / accepted template mimetype). Export: **Save as Template** writes `.dotx` (template content type) via `DocxTemplateExport`. Five templates (Markdown, APA, MLA, Screenplay, Resume) ship as bundled `.dotx` assets (`loki-templates`) and open from the home gallery. | | **ODT export** | Yes | — | Yes | `loki-odf`'s `OdtExport` writes a full ODT package (`content.xml` / `styles.xml` / `meta.xml` + `Pictures/`). **Lossless** for: the complete character-property set (fonts incl. complex/East-Asian, size, weight, italic, underline, strike, caps, outline, shadow, super/sub, colour, letter/word spacing, kerning, scale, languages), the complete paragraph-property set (alignment, indents, spacing, line height, keep/widow/orphan/break flags, borders, padding, tab stops, bidi, background), the named style catalog, **multi-section page geometry** (each section gets its own `style:page-layout` + `style:master-page`; section breaks are emitted as `style:master-page-name` on the first paragraph of each section, the form the importer reads back), **headers/footers** (default/first/even, written per-section into the master page with their own automatic styles + images), headings, styled paragraphs, lists, tables, footnotes/endnotes, links, **bookmarks, fields, and embedded images** (decoded from data URIs and written as `Pictures/` parts), and core Dublin Core metadata. A property-level round-trip test asserts each survives. Editing an opened `.odt` and saving round-trips to ODT. **Multi-column sections** (`style:columns` with count/gap/separator), **extended Dublin Core** (publisher, contributors, rights, license, identifier, type, format, source, relation, coverage, issued, citation — carried as `meta:user-defined` entries under reserved `dcmi:` names), and **comments** (`office:annotation` / `office:annotation-end`) also round-trip. **Math** is emitted as embedded formula objects (`draw:object` → `Object N/content.xml` MathML, listed in the manifest). Still not emitted: the OTT template content type. | -| **Reflow (non-paginated) view** | — | Yes | — | `LayoutMode::Reflow` + `RenderMode::Reflow` render a continuous web-style flow through the same layout/Vello pipeline as paginated view (full font/size/alignment fidelity), sliced into zero-gap GPU band tiles (768pt ⇒ exact 1024 CSS px, so tiles stack seamlessly). Relayouts to the window width on resize (shell re-emits `onscroll` for scroll containers). **Bounded reading measure (Spec 03 M4):** the reflow tile is capped at `MAX_REFLOW_TILE_PX` (820 CSS px) and centred (`margin: auto`) on wide windows, so the line length stays comfortable instead of running edge-to-edge; narrow screens still use their full width. A single `render_layout::{reflow_tile_width_px, reflow_content_width_pt}` feeds paint, hit-test, and keyboard nav so they stay aligned (the HTML fallback references the same constant). Content wider than the viewport (e.g. a fixed-width table) widens the tiles so it is reachable by horizontal scrolling rather than clipped. No headers/footers/page chrome by design; the status-bar page indicator is hidden in reflow. **Editing:** `ContinuousLayout` carries per-paragraph editing data, so click-to-cursor, caret placement/painting, range-selection highlighting (mouse drag-select + Shift+Arrow), and reflow-native arrow / Home / End navigation all work, plus typing/undo/formatting. Still missing: typing/Backspace over a selection does not yet delete the selected range first (it inserts at the focus), and touch long-press selection is not wired for reflow. Android CPU builds (no `android_gpu`) fall back to a low-fidelity HTML flow (`reflow_view.rs`) with no caret. | +| **Reflow (non-paginated) view** | — | Yes | — | `LayoutMode::Reflow` + `RenderMode::Reflow` render a continuous web-style flow through the same layout/Vello pipeline as paginated view (full font/size/alignment fidelity), sliced into zero-gap GPU band tiles (768pt ⇒ exact 1024 CSS px, so tiles stack seamlessly). Relayouts to the window width on resize (shell re-emits `onscroll` for scroll containers). **Bounded reading measure (Spec 03 M4):** the reflow tile is capped at `MAX_REFLOW_TILE_PX` (820 CSS px) and centred (`margin: auto`) on wide windows, so the line length stays comfortable instead of running edge-to-edge; narrow screens still use their full width. A single `render_layout::{reflow_tile_width_px, reflow_content_width_pt}` feeds paint, hit-test, and keyboard nav so they stay aligned (the HTML fallback references the same constant). Content wider than the viewport (e.g. a fixed-width table) widens the tiles so it is reachable by horizontal scrolling rather than clipped. No headers/footers/page chrome by design; the status-bar page indicator is hidden in reflow. **Editing:** `ContinuousLayout` carries per-paragraph editing data, so click-to-cursor, caret placement/painting, range-selection highlighting (mouse drag-select + Shift+Arrow), and reflow-native arrow / Home / End navigation all work, plus typing/undo/formatting. Selection editing is view-independent and works here too: typing replaces the active selection and Backspace/Delete remove it, incl. multi-block ranges (`delete_selection_at` + `editor_keydown_text.rs`, 2026-07-05). Still missing: touch long-press selection is not wired for reflow. Android CPU builds (no `android_gpu`) fall back to a low-fidelity HTML flow (`reflow_view.rs`) with no caret. | --- diff --git a/loki-doc-model/src/lib.rs b/loki-doc-model/src/lib.rs index bc649ecd..a679fa35 100644 --- a/loki-doc-model/src/lib.rs +++ b/loki-doc-model/src/lib.rs @@ -149,8 +149,8 @@ pub mod loro_bridge; pub use loro_bridge::{BridgeError, IncrementalReader, document_to_loro, loro_to_document}; pub mod loro_mutation; pub use loro_mutation::{ - BlockPath, PathStep, delete_text_at, get_block_text_at, get_mark_at_path, insert_text_at, - mark_text_at, + BlockPath, PathStep, delete_selection_at, delete_text_at, get_block_text_at, get_mark_at_path, + insert_text_at, mark_text_at, }; pub use loro_mutation::{ MutationError, delete_text, get_block_alignment, get_block_style_name, get_block_text, diff --git a/loki-doc-model/src/loro_mutation/mod.rs b/loki-doc-model/src/loro_mutation/mod.rs index 2cc30a9e..23e26a58 100644 --- a/loki-doc-model/src/loro_mutation/mod.rs +++ b/loki-doc-model/src/loro_mutation/mod.rs @@ -30,6 +30,7 @@ mod block; mod nested; #[cfg(feature = "serde")] mod objects; +mod selection; mod style; mod text; @@ -42,6 +43,7 @@ pub use self::nested::{ }; #[cfg(feature = "serde")] pub use self::objects::{insert_inline_image_at, insert_inline_note_at}; +pub use self::selection::delete_selection_at; pub use self::style::{ get_block_alignment, get_block_style_name, set_block_alignment, set_block_style, set_block_type_heading, set_block_type_para, @@ -88,6 +90,11 @@ pub enum MutationError { /// range. #[error("Invalid block path: {0}")] InvalidBlockPath(String), + /// A selection operation was given endpoints in different containers + /// (body ↔ table cell, cell ↔ cell, note ↔ body, or different sections). + /// Nothing is mutated. + #[error("Selection endpoints are in different containers")] + CrossContainerSelection, } impl From for MutationError { diff --git a/loki-doc-model/src/loro_mutation/selection.rs b/loki-doc-model/src/loro_mutation/selection.rs new file mode 100644 index 00000000..664a8dab --- /dev/null +++ b/loki-doc-model/src/loro_mutation/selection.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Selection deletion: removing a contiguous text range that may span several +//! sibling blocks (Spec/audit F6c — typing and Backspace must operate on the +//! active selection). +//! +//! The range is expressed as two `(BlockPath, byte_offset)` endpoints in +//! either order. Both endpoints must lie in the **same container** (the same +//! top-level section space, the same table cell, or the same note body) — +//! a selection that crosses a container boundary (body ↔ cell, cell ↔ cell) +//! is rejected with [`MutationError::CrossContainerSelection`], mirroring the +//! formatting layer's clamping rule. +//! +//! A multi-block deletion is composed from the existing primitives: +//! every following block in the range is [`merge_block_at`]-ed into the +//! first (the merge concatenates text and removes the merged block), and one +//! [`delete_text_at`] then removes `[start_byte, join + end_byte)` from the +//! merged text — the tail of the first block, every middle block's text, and +//! the head of the last. Word's behaviour falls out naturally: the surviving +//! paragraph keeps the *first* block's style. + +use loro::{ContainerTrait, LoroDoc}; + +use super::nested::{BlockPath, PathStep, resolve_block_list, text_for_path}; +use super::{MutationError, delete_text_at, merge_block_at}; + +/// The block index of `path`'s leaf within its container (the root index for +/// a top-level path, the leaf step's block index for a nested one). +fn leaf_index(path: &BlockPath) -> usize { + match path.steps.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => *block, + None => path.root, + } +} + +/// `path` with its leaf block index replaced by `leaf`. +fn with_leaf(path: &BlockPath, leaf: usize) -> BlockPath { + let mut p = path.clone(); + match p.steps.last_mut() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => *block = leaf, + None => p.root = leaf, + } + p +} + +/// Whether two paths address sibling blocks of one container: both top-level, +/// or nested with identical root, identical non-leaf steps, and the same leaf +/// cell / note. +fn same_container(a: &BlockPath, b: &BlockPath) -> bool { + if a.steps.len() != b.steps.len() { + return false; + } + let Some(n) = a.steps.len().checked_sub(1) else { + return true; // both top-level; cross-section is caught by list identity + }; + if a.root != b.root || a.steps[..n] != b.steps[..n] { + return false; + } + match (a.steps[n], b.steps[n]) { + (PathStep::Cell { cell: c1, .. }, PathStep::Cell { cell: c2, .. }) => c1 == c2, + (PathStep::Note { note: n1, .. }, PathStep::Note { note: n2, .. }) => n1 == n2, + _ => false, + } +} + +/// Deletes the text between two positions (in either order), collapsing any +/// blocks the range spans. Returns the collapsed cursor position — the +/// ordered start endpoint. +/// +/// # Errors +/// +/// - [`MutationError::CrossContainerSelection`] — the endpoints live in +/// different containers (or different sections); nothing is mutated. +/// - [`MutationError::TextNotFound`] — a block inside the range carries no +/// editable text (e.g. a table or horizontal rule); nothing is mutated +/// (the whole range is validated before the first mutation). +/// - Any error the underlying primitives report. +pub fn delete_selection_at( + loro: &LoroDoc, + a: (&BlockPath, usize), + b: (&BlockPath, usize), +) -> Result<(BlockPath, usize), MutationError> { + if !same_container(a.0, b.0) { + return Err(MutationError::CrossContainerSelection); + } + // Normalize the endpoints into document order within the container. + let ((start_path, start_byte), (end_path, end_byte)) = + if (leaf_index(a.0), a.1) <= (leaf_index(b.0), b.1) { + (a, b) + } else { + (b, a) + }; + let start_leaf = leaf_index(start_path); + let end_leaf = leaf_index(end_path); + + // Single-block selection: one plain text deletion. + if start_leaf == end_leaf { + if end_byte > start_byte { + delete_text_at(loro, start_path, start_byte, end_byte - start_byte)?; + } + return Ok((start_path.clone(), start_byte)); + } + + // Validate the whole range BEFORE mutating, so unsupported content inside + // the selection (a table, a rule, a section break) rejects the operation + // instead of leaving it half-applied. + let (start_list, _) = resolve_block_list(loro, start_path)?; + let (end_list, _) = resolve_block_list(loro, end_path)?; + if start_list.id() != end_list.id() { + return Err(MutationError::CrossContainerSelection); + } + for leaf in start_leaf..=end_leaf { + text_for_path(loro, &with_leaf(start_path, leaf))?; + } + + // Merge every following block in the range into the start block. Each + // merge removes the merged block, so the next one is always at + // `start_leaf + 1`. The final join offset is where the end block's text + // begins within the merged text. + let merge_path = with_leaf(start_path, start_leaf + 1); + let mut join = 0usize; + for _ in start_leaf..end_leaf { + join = merge_block_at(loro, &merge_path)?; + } + delete_text_at(loro, start_path, start_byte, join + end_byte - start_byte)?; + Ok((start_path.clone(), start_byte)) +} diff --git a/loki-doc-model/tests/loro_selection_delete_tests.rs b/loki-doc-model/tests/loro_selection_delete_tests.rs new file mode 100644 index 00000000..ae7c69e0 --- /dev/null +++ b/loki-doc-model/tests/loro_selection_delete_tests.rs @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Selection deletion ([`delete_selection_at`]): removing ranges that span one +//! or several sibling blocks, in either endpoint order, at the top level and +//! inside table cells — plus the rejection cases (cross-container endpoints, +//! non-text blocks inside the range) proving nothing is half-applied. + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::content::table::core::{Table, TableBody, TableCaption, TableFoot, TableHead}; +use loki_doc_model::content::table::row::{Cell, Row}; +use loki_doc_model::document::Document; +use loki_doc_model::layout::section::Section; +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document}; +use loki_doc_model::{BlockPath, MutationError, delete_selection_at}; + +fn para(s: &str) -> Block { + Block::Para(vec![Inline::Str(s.into())]) +} + +/// Plain text of every top-level paragraph of section `s`. +fn para_texts(doc: &Document, s: usize) -> Vec { + doc.sections[s] + .blocks + .iter() + .filter_map(|b| match b { + Block::Para(inlines) => Some( + inlines + .iter() + .filter_map(|i| match i { + Inline::Str(t) => Some(t.as_str()), + _ => None, + }) + .collect(), + ), + _ => None, + }) + .collect() +} + +fn three_para_doc() -> Document { + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("Hello world"), para("middle"), para("goodbye")]; + doc +} + +#[test] +fn deletes_within_a_single_block() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + // "Hello world" -> "Helld" (delete "lo wor", bytes 3..9). + let p = BlockPath::block(0); + let (path, byte) = delete_selection_at(&loro, (&p, 3), (&p, 9)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(0), 3)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["Helld", "middle", "goodbye"]); +} + +#[test] +fn a_collapsed_selection_deletes_nothing() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + let p = BlockPath::block(1); + let (path, byte) = delete_selection_at(&loro, (&p, 3), (&p, 3)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(1), 3)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!( + para_texts(&rebuilt, 0), + vec!["Hello world", "middle", "goodbye"] + ); +} + +#[test] +fn collapses_a_range_spanning_three_blocks() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + // From byte 6 of "Hello world" to byte 4 of "goodbye": the tail of block + // 0, all of "middle", and "good" vanish; the survivor keeps block 0's + // head and block 2's tail. + let (start, end) = (BlockPath::block(0), BlockPath::block(2)); + let (path, byte) = delete_selection_at(&loro, (&start, 6), (&end, 4)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(0), 6)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["Hello bye"]); +} + +#[test] +fn endpoints_normalize_in_either_order() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + // Same range as above, endpoints swapped (focus before anchor). + let (a, b) = (BlockPath::block(2), BlockPath::block(0)); + let (path, byte) = delete_selection_at(&loro, (&a, 4), (&b, 6)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(0), 6)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["Hello bye"]); +} + +#[test] +fn reversed_offsets_within_one_block_normalize() { + let loro = document_to_loro(&three_para_doc()).unwrap(); + let p = BlockPath::block(0); + let (path, byte) = delete_selection_at(&loro, (&p, 9), (&p, 3)).unwrap(); + assert_eq!((path, byte), (BlockPath::block(0), 3)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["Helld", "middle", "goodbye"]); +} + +// ── Nested containers ─────────────────────────────────────────────────────── + +/// `[Para("intro"), Table]` — the table (global block 1) has one body row of +/// two cells; cell 0 holds paragraphs "alpha" | "beta", cell 1 holds "z". +fn doc_with_two_block_cell() -> Document { + let cell = Cell::simple(vec![para("alpha"), para("beta")]); + let table = Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![ + cell, + Cell::simple(vec![para("z")]), + ])])], + foot: TableFoot::empty(), + }; + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("intro"), Block::Table(Box::new(table))]; + doc +} + +/// Plain text of every paragraph in the `cell`-th cell of the table at global +/// block index 1. +fn cell_block_texts(doc: &Document, cell: usize) -> Vec { + let Block::Table(t) = &doc.sections[0].blocks[1] else { + panic!("expected a table"); + }; + t.bodies[0].body_rows[0].cells[cell] + .blocks + .iter() + .filter_map(|b| match b { + Block::Para(inlines) => Some( + inlines + .iter() + .filter_map(|i| match i { + Inline::Str(s) => Some(s.as_str()), + _ => None, + }) + .collect(), + ), + _ => None, + }) + .collect() +} + +#[test] +fn collapses_a_range_inside_one_table_cell() { + let loro = document_to_loro(&doc_with_two_block_cell()).unwrap(); + // "alpha"|"beta" -> "alta": from byte 2 of "alpha" to byte 2 of "beta". + let (a, b) = (BlockPath::in_cell(1, 0, 0), BlockPath::in_cell(1, 0, 1)); + let (path, byte) = delete_selection_at(&loro, (&a, 2), (&b, 2)).unwrap(); + assert_eq!((path, byte), (BlockPath::in_cell(1, 0, 0), 2)); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(cell_block_texts(&rebuilt, 0), vec!["alta"]); + assert_eq!(cell_block_texts(&rebuilt, 1), vec!["z"]); +} + +#[test] +fn rejects_endpoints_in_different_cells_without_mutating() { + let loro = document_to_loro(&doc_with_two_block_cell()).unwrap(); + let (a, b) = (BlockPath::in_cell(1, 0, 0), BlockPath::in_cell(1, 1, 0)); + let err = delete_selection_at(&loro, (&a, 1), (&b, 1)); + assert!(matches!(err, Err(MutationError::CrossContainerSelection))); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(cell_block_texts(&rebuilt, 0), vec!["alpha", "beta"]); + assert_eq!(cell_block_texts(&rebuilt, 1), vec!["z"]); +} + +#[test] +fn rejects_a_body_to_cell_selection_without_mutating() { + let loro = document_to_loro(&doc_with_two_block_cell()).unwrap(); + let (a, b) = (BlockPath::block(0), BlockPath::in_cell(1, 0, 0)); + let err = delete_selection_at(&loro, (&a, 1), (&b, 1)); + assert!(matches!(err, Err(MutationError::CrossContainerSelection))); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["intro"]); + assert_eq!(cell_block_texts(&rebuilt, 0), vec!["alpha", "beta"]); +} + +#[test] +fn rejects_a_range_containing_a_table_without_mutating() { + // [Para, Table, Para]: a top-level selection from block 0 to block 2 would + // swallow the table — the pre-validation must reject it untouched. + let mut doc = doc_with_two_block_cell(); + doc.sections[0].blocks.push(para("outro")); + let loro = document_to_loro(&doc).unwrap(); + let (a, b) = (BlockPath::block(0), BlockPath::block(2)); + let err = delete_selection_at(&loro, (&a, 1), (&b, 1)); + assert!(matches!(err, Err(MutationError::TextNotFound(_)))); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["intro", "outro"]); + assert_eq!(cell_block_texts(&rebuilt, 0), vec!["alpha", "beta"]); +} + +#[test] +fn rejects_a_cross_section_selection_without_mutating() { + // Two sections of one paragraph each: global blocks 0 and 1 are top-level + // siblings by index but live in different section lists. + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("first")]; + let mut s1 = Section::new(); + s1.blocks = vec![para("second")]; + doc.sections.push(s1); + let loro = document_to_loro(&doc).unwrap(); + let (a, b) = (BlockPath::block(0), BlockPath::block(1)); + let err = delete_selection_at(&loro, (&a, 1), (&b, 1)); + assert!(matches!(err, Err(MutationError::CrossContainerSelection))); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(para_texts(&rebuilt, 0), vec!["first"]); + assert_eq!(para_texts(&rebuilt, 1), vec!["second"]); +} diff --git a/loki-text/src/routes/editor/editor_keydown.rs b/loki-text/src/routes/editor/editor_keydown.rs index 105cba67..db5f0ff3 100644 --- a/loki-text/src/routes/editor/editor_keydown.rs +++ b/loki-text/src/routes/editor/editor_keydown.rs @@ -6,20 +6,18 @@ use std::sync::{Arc, Mutex}; use dioxus::prelude::*; use keyboard_types::Modifiers; -use loki_doc_model::loro_mutation::{ - delete_text_at, get_block_text, get_block_text_at, insert_text_at, -}; -use loki_doc_model::merge_block_at; +use loki_doc_model::loro_mutation::get_block_text; use loki_renderer::ViewMode; use loki_renderer::render_layout::reflow_content_width_pt; -use super::editor_keydown_ctrl::{ - handle_ctrl_keys, handle_delete_key, handle_enter_key, post_mutation_sync, +use super::editor_keydown_ctrl::{handle_ctrl_keys, handle_delete_key, handle_enter_key}; +use super::editor_keydown_text::{ + SelectionRemoval, handle_backspace_key, handle_character_key, remove_selection, }; use super::editor_scrollbar::ScrollMetrics; -use crate::editing::cursor::{CursorState, DocumentPosition, prev_grapheme_boundary}; +use crate::editing::cursor::CursorState; use crate::editing::navigation::{ navigate_down, navigate_end, navigate_home, navigate_left, navigate_right, navigate_up, }; @@ -27,7 +25,7 @@ use crate::editing::reflow_nav::{ reflow_navigate_down, reflow_navigate_end, reflow_navigate_home, reflow_navigate_left, reflow_navigate_right, reflow_navigate_up, }; -use crate::editing::state::{DocumentState, apply_mutation_and_relayout, ensure_reflow_layout}; +use crate::editing::state::{DocumentState, ensure_reflow_layout}; // EditorMode removed — the editor is always in edit mode when a document is // open. Distraction-free reading is handled by the View ribbon tab (future @@ -82,128 +80,58 @@ pub(super) fn make_keydown_handler( let Some(focus) = focus else { return }; match &key { - // ── Printable characters ────────────────────────────────────────── + // ── Printable characters (replace the selection if one is active) ─ Key::Character(ch) => { - let ch = ch.clone(); - { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - if insert_text_at(ldoc, &focus.block_path(), focus.byte_offset, &ch).is_err() { - return; - } - } - { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - apply_mutation_and_relayout(&doc_state, ldoc); - } - post_mutation_sync( - &doc_state, + handle_character_key( + ch.clone(), + focus, loro_doc, + &doc_state, cursor_state, undo_manager, can_undo, can_redo, ); - let new_offset = focus.byte_offset + ch.len(); - let new_pos = DocumentPosition { - byte_offset: new_offset, - ..focus - }; - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); } - // ── Backspace ───────────────────────────────────────────────────── + // ── Backspace (selection removal / block merge / grapheme) ──────── Key::Backspace => { - if focus.byte_offset == 0 { - // Backspace-at-start merges this block into its previous - // sibling within the same container. `merge_block_at` returns - // `NoPreviousBlock` at the first block of a container (a - // top-level paragraph 0 or the first block of a cell / note - // body), making this a no-op there. - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - let Ok(merged_offset) = merge_block_at(ldoc, &focus.block_path()) else { - return; - }; - apply_mutation_and_relayout(&doc_state, ldoc); - post_mutation_sync( - &doc_state, - loro_doc, - cursor_state, - undo_manager, - can_undo, - can_redo, - ); - // TODO(3b-3): recompute page_index from layout after merge. - // Caret lands at the join point in the previous sibling block. - let new_pos = focus.sibling_block(-1, merged_offset); - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); - return; - } - let text = { - let ldoc_guard = loro_doc.read(); - ldoc_guard - .as_ref() - .map(|l| get_block_text_at(l, &focus.block_path())) - .unwrap_or_default() - }; - let prev = prev_grapheme_boundary(&text, focus.byte_offset); - let len = focus.byte_offset - prev; - { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - if delete_text_at(ldoc, &focus.block_path(), prev, len).is_err() { - return; - } - } - { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - apply_mutation_and_relayout(&doc_state, ldoc); - } - post_mutation_sync( - &doc_state, + handle_backspace_key( + focus, loro_doc, + &doc_state, cursor_state, undo_manager, can_undo, can_redo, ); - let new_pos = DocumentPosition { - byte_offset: prev, - ..focus - }; - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); } // ── Forward delete ──────────────────────────────────────────────── Key::Delete => { - handle_delete_key( - focus, - loro_doc, - &doc_state, - cursor_state, - undo_manager, - can_undo, - can_redo, - ); + // An active selection is removed instead of the next grapheme; + // a rejected (cross-container) selection swallows the key. + if matches!( + remove_selection( + loro_doc, + &doc_state, + cursor_state, + undo_manager, + can_undo, + can_redo, + ), + SelectionRemoval::NoSelection + ) { + handle_delete_key( + focus, + loro_doc, + &doc_state, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + } } // ── Arrow / Home / End navigation (mode-aware) ──────────────────── diff --git a/loki-text/src/routes/editor/editor_keydown_text.rs b/loki-text/src/routes/editor/editor_keydown_text.rs new file mode 100644 index 00000000..a12ddbd6 --- /dev/null +++ b/loki-text/src/routes/editor/editor_keydown_text.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Printable-character and Backspace handling for the document canvas, +//! including selection-aware replacement and removal (audit F6c): typing +//! replaces the active selection, Backspace/Delete remove it. +//! +//! Extracted from `editor_keydown.rs` to keep that file under the 300-line +//! ceiling. Called by [`super::editor_keydown::make_keydown_handler`]. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use loki_doc_model::loro_mutation::{delete_text_at, get_block_text_at, insert_text_at}; +use loki_doc_model::{PathStep, delete_selection_at, merge_block_at}; + +use super::editor_keydown_ctrl::post_mutation_sync; +use crate::editing::cursor::{CursorState, DocumentPosition, prev_grapheme_boundary}; +use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; + +#[cfg(test)] +#[path = "editor_keydown_text_tests.rs"] +mod tests; + +/// What [`remove_selection`] did with the active selection. +pub(super) enum SelectionRemoval { + /// No range selection was active — the caller performs its normal + /// single-cursor action. + NoSelection, + /// The selected range was deleted and the cursor collapsed to its start. + Removed, + /// A selection was active but the model rejected the range (endpoints in + /// different containers, or a non-text block inside it). Nothing was + /// mutated; the caller must NOT fall through to a single-cursor edit — + /// swallowing the key beats surprising the user with a stray deletion. + Rejected, +} + +/// The selection endpoint that comes first in document order, using the same +/// `(leaf block index, byte offset)` normalization as +/// [`delete_selection_at`] — so the collapsed cursor keeps the right +/// `page_index`. +fn selection_start(a: &DocumentPosition, b: &DocumentPosition) -> DocumentPosition { + fn leaf(p: &DocumentPosition) -> usize { + match p.path.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => *block, + None => p.paragraph_index, + } + } + if (leaf(a), a.byte_offset) <= (leaf(b), b.byte_offset) { + a.clone() + } else { + b.clone() + } +} + +/// Deletes the active selection in the CRDT only — no relayout or undo +/// commit, so a caller can batch a follow-up insert into the same undo entry. +/// +/// Returns the collapsed cursor position, or `None` when there is no active +/// selection or the model rejected the range (nothing mutated either way). +fn delete_selection_in_doc(ldoc: &loro::LoroDoc, cursor: &CursorState) -> Option { + if !cursor.has_selection() { + return None; + } + let (anchor, focus) = (cursor.anchor.clone()?, cursor.focus.clone()?); + let (_, byte) = delete_selection_at( + ldoc, + (&anchor.block_path(), anchor.byte_offset), + (&focus.block_path(), focus.byte_offset), + ) + .ok()?; + Some(DocumentPosition { + byte_offset: byte, + ..selection_start(&anchor, &focus) + }) +} + +/// Removes the active selection (Backspace/Delete over a range): mutates, +/// relayouts, syncs, and collapses the cursor to the range start. +#[allow(clippy::too_many_arguments)] // mirrors the other keydown helpers' signals +pub(super) fn remove_selection( + loro_doc: Signal>, + doc_state: &Arc>, + mut cursor_state: Signal, + undo_manager: Signal>, + can_undo: Signal, + can_redo: Signal, +) -> SelectionRemoval { + if !cursor_state.read().has_selection() { + return SelectionRemoval::NoSelection; + } + let collapsed = { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return SelectionRemoval::Rejected; + }; + let Some(pos) = delete_selection_in_doc(ldoc, &cursor_state.read()) else { + return SelectionRemoval::Rejected; + }; + apply_mutation_and_relayout(doc_state, ldoc); + pos + }; + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + let mut cs = cursor_state.write(); + cs.focus = Some(collapsed.clone()); + cs.anchor = Some(collapsed); + SelectionRemoval::Removed +} + +/// Handles a printable character: replaces the active selection (if any), +/// inserts the character, and places the caret after it. +/// +/// The selection delete and the insert share one relayout + commit, so +/// replace-typing is a single undo entry. +#[allow(clippy::too_many_arguments)] // mirrors the other keydown helpers' signals +pub(super) fn handle_character_key( + ch: String, + focus: DocumentPosition, + loro_doc: Signal>, + doc_state: &Arc>, + mut cursor_state: Signal, + undo_manager: Signal>, + can_undo: Signal, + can_redo: Signal, +) { + let insert_at = { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + let insert_at = if cursor_state.read().has_selection() { + // Replace-typing. A rejected range swallows the keystroke. + let Some(pos) = delete_selection_in_doc(ldoc, &cursor_state.read()) else { + return; + }; + pos + } else { + focus + }; + if insert_text_at(ldoc, &insert_at.block_path(), insert_at.byte_offset, &ch).is_err() { + return; + } + apply_mutation_and_relayout(doc_state, ldoc); + insert_at + }; + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + let new_pos = DocumentPosition { + byte_offset: insert_at.byte_offset + ch.len(), + ..insert_at + }; + let mut cs = cursor_state.write(); + cs.focus = Some(new_pos.clone()); + cs.anchor = Some(new_pos); +} + +/// Handles Backspace: removes the active selection, or merges with the +/// previous block at offset 0, or deletes the previous grapheme. +#[allow(clippy::too_many_arguments)] // mirrors the other keydown helpers' signals +pub(super) fn handle_backspace_key( + focus: DocumentPosition, + loro_doc: Signal>, + doc_state: &Arc>, + mut cursor_state: Signal, + undo_manager: Signal>, + can_undo: Signal, + can_redo: Signal, +) { + match remove_selection( + loro_doc, + doc_state, + cursor_state, + undo_manager, + can_undo, + can_redo, + ) { + SelectionRemoval::Removed | SelectionRemoval::Rejected => return, + SelectionRemoval::NoSelection => {} + } + if focus.byte_offset == 0 { + // Backspace-at-start merges this block into its previous sibling + // within the same container. `merge_block_at` returns + // `NoPreviousBlock` at the first block of a container (a top-level + // paragraph 0 or the first block of a cell / note body), making this + // a no-op there. + let merged_offset = { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + let Ok(merged_offset) = merge_block_at(ldoc, &focus.block_path()) else { + return; + }; + apply_mutation_and_relayout(doc_state, ldoc); + merged_offset + }; + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + // TODO(3b-3): recompute page_index from layout after merge. + // Caret lands at the join point in the previous sibling block. + let new_pos = focus.sibling_block(-1, merged_offset); + let mut cs = cursor_state.write(); + cs.focus = Some(new_pos.clone()); + cs.anchor = Some(new_pos); + return; + } + let prev = { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + let text = get_block_text_at(ldoc, &focus.block_path()); + let prev = prev_grapheme_boundary(&text, focus.byte_offset); + let len = focus.byte_offset - prev; + if delete_text_at(ldoc, &focus.block_path(), prev, len).is_err() { + return; + } + apply_mutation_and_relayout(doc_state, ldoc); + prev + }; + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + let new_pos = DocumentPosition { + byte_offset: prev, + ..focus + }; + let mut cs = cursor_state.write(); + cs.focus = Some(new_pos.clone()); + cs.anchor = Some(new_pos); +} diff --git a/loki-text/src/routes/editor/editor_keydown_text_tests.rs b/loki-text/src/routes/editor/editor_keydown_text_tests.rs new file mode 100644 index 00000000..62956bce --- /dev/null +++ b/loki-text/src/routes/editor/editor_keydown_text_tests.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use loki_doc_model::PathStep; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::{document_to_loro, loro_to_document}; +use loki_doc_model::loro_mutation::get_block_text; + +use super::{delete_selection_in_doc, selection_start}; +use crate::editing::cursor::{CursorState, DocumentPosition}; + +fn para(s: &str) -> Block { + Block::Para(vec![Inline::Str(s.into())]) +} + +fn loro_with_paras(texts: &[&str]) -> loro::LoroDoc { + let mut doc = Document::new(); + doc.sections[0].blocks = texts.iter().map(|t| para(t)).collect(); + document_to_loro(&doc).unwrap() +} + +fn selection(anchor: DocumentPosition, focus: DocumentPosition) -> CursorState { + let mut cs = CursorState::new(); + cs.anchor = Some(anchor); + cs.focus = Some(focus); + cs +} + +#[test] +fn selection_start_orders_top_level_positions() { + let a = DocumentPosition::top_level(0, 2, 1); + let b = DocumentPosition::top_level(1, 0, 9); + // Block 0 precedes block 2 regardless of byte offsets or endpoint order. + assert_eq!(selection_start(&a, &b), b); + assert_eq!(selection_start(&b, &a), b); +} + +#[test] +fn selection_start_orders_by_byte_within_one_block() { + let a = DocumentPosition::top_level(0, 1, 7); + let b = DocumentPosition::top_level(0, 1, 3); + assert_eq!(selection_start(&a, &b), b); +} + +#[test] +fn selection_start_orders_nested_positions_by_leaf_block() { + let mk = |block: usize, byte: usize| DocumentPosition { + page_index: 0, + paragraph_index: 1, + byte_offset: byte, + path: vec![PathStep::Cell { cell: 0, block }], + }; + assert_eq!(selection_start(&mk(1, 0), &mk(0, 5)), mk(0, 5)); +} + +#[test] +fn no_selection_deletes_nothing() { + let loro = loro_with_paras(&["hello"]); + let mut cs = CursorState::new(); + cs.anchor = Some(DocumentPosition::top_level(0, 0, 2)); + cs.focus = cs.anchor.clone(); // point cursor, not a range + assert_eq!(delete_selection_in_doc(&loro, &cs), None); + assert_eq!(get_block_text(&loro, 0), "hello"); +} + +#[test] +fn same_block_selection_collapses_to_range_start() { + let loro = loro_with_paras(&["hello world"]); + // Focus before anchor (backwards drag): bytes 3..9 selected. + let cs = selection( + DocumentPosition::top_level(0, 0, 9), + DocumentPosition::top_level(0, 0, 3), + ); + let pos = delete_selection_in_doc(&loro, &cs).unwrap(); + assert_eq!(pos, DocumentPosition::top_level(0, 0, 3)); + assert_eq!(get_block_text(&loro, 0), "helld"); +} + +#[test] +fn cross_block_selection_keeps_the_start_pages_index() { + let loro = loro_with_paras(&["first", "second", "third"]); + // Anchor on (page 3, block 2), focus on (page 1, block 0): the collapsed + // cursor is the ordered start — focus's page. + let cs = selection( + DocumentPosition::top_level(3, 2, 3), + DocumentPosition::top_level(1, 0, 2), + ); + let pos = delete_selection_in_doc(&loro, &cs).unwrap(); + assert_eq!(pos, DocumentPosition::top_level(1, 0, 2)); + assert_eq!(get_block_text(&loro, 0), "fird"); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!(rebuilt.sections[0].blocks.len(), 1); +} + +#[test] +fn rejected_cross_container_selection_mutates_nothing() { + let loro = loro_with_paras(&["top level"]); + // Anchor at top level, focus inside a (nonexistent, but structurally + // nested) cell path — different containers, rejected before resolution. + let cs = selection( + DocumentPosition::top_level(0, 0, 1), + DocumentPosition { + page_index: 0, + paragraph_index: 0, + byte_offset: 1, + path: vec![PathStep::Cell { cell: 0, block: 0 }], + }, + ); + assert_eq!(delete_selection_in_doc(&loro, &cs), None); + assert_eq!(get_block_text(&loro, 0), "top level"); +} diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index 4597b010..b364bb9d 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -20,6 +20,7 @@ mod editor_insert; mod editor_insert_panel; mod editor_keydown; mod editor_keydown_ctrl; +mod editor_keydown_text; mod editor_language_panel; mod editor_layout_task; mod editor_load; From a97e86044e8700903bd529354aa6b00197c2284e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:02:58 +0000 Subject: [PATCH 16/27] Undo-stack clean checkpoint: undoing back to the save point clears dirty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b.3 of the deferred-features plan (TODO(undo-dirty)): the tab dirty indicator compared a forward-only generation counter against a baseline, so undoing every edit back to the saved state still showed the document as dirty. New crate::editing::saved_state module (loki-text): - SavedStateHandle tracks the undo-stack depth of the last save via the loro UndoManager on_push/on_pop hooks, giving classic clean-index semantics: save records the depth, undo/redo returning the stack to that depth means the document equals the file again, and a fresh edit made below the save point (which truncates the redo path back to it) marks the saved state permanently unreachable until the next save. - The fresh-edit vs redo-replay discriminator is loro's third on_push argument (Some(DiffEvent) for local edits, None for the pushes undo()/redo() perform internally) — locked in by 6 integration tests against a real UndoManager (saved_state_tests.rs), including the truncation case and saving with a live redo stack. Wiring: - The tracker is attached alongside every UndoManager creation (load path, post-save compaction swap) and rides with it through the tab-switch session stash (DocSession/PathSyncSignals). - Ctrl+S success calls record_new_checkpoint() and marks the clean depth before compaction; the dirty effect now reads dirty = untitled || (generation-differs && !undo_clean). To keep editor_inner.rs inside the file-ceiling ratchet, the Save As callback moved to editor_save_callbacks.rs (use_save_as_callback) — editor_inner.rs shrinks 870 -> 833 and the baseline is ratcheted. Remaining 4b.3 tail (noted in the plan): typed SaveError residual and disabling the ribbon Save button when the document is clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 2 +- loki-text/src/editing/mod.rs | 1 + loki-text/src/editing/saved_state.rs | 136 ++++++++++++++++++ loki-text/src/editing/saved_state_tests.rs | 120 ++++++++++++++++ loki-text/src/routes/editor/editor_compact.rs | 11 +- loki-text/src/routes/editor/editor_inner.rs | 104 +++++--------- .../src/routes/editor/editor_path_sync.rs | 5 + .../routes/editor/editor_save_callbacks.rs | 79 +++++++++- loki-text/src/routes/editor/editor_state.rs | 15 +- loki-text/src/sessions.rs | 2 + scripts/file-ceiling-baseline.txt | 2 +- 12 files changed, 391 insertions(+), 88 deletions(-) create mode 100644 loki-text/src/editing/saved_state.rs create mode 100644 loki-text/src/editing/saved_state_tests.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 7618b57b..66bae030 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -62,7 +62,7 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | `odf-master-page` | `odf/reader/styles.rs:200` | ODF master-page transitions | | `odt-fidelity` | `editor_load.rs:84,88` | Tracked DOCX/ODT import gaps | | `formatting` | `editor_formatting.rs:106` | Multi-block-selection formatting (clamped to focus paragraph) | -| `undo-dirty` | `editor_state.rs:118` | Saved-vs-undo-stack clean tracking (Save not implemented) | +| `undo-dirty` | `editor_state.rs:118` | ~~Saved-vs-undo-stack clean tracking~~ **fixed 2026-07-05** (plan 4b.3): `editing/saved_state.rs` clean checkpoint — undoing back to the save point clears dirty | | `nested-nav` | `navigation.rs:138,174` | Sibling path inside cell/note body | | `tabs` | `shell.rs:148`, `home.rs:89` | Tab-driven (vs router) navigation; blank-doc | | `ux` | `text/home.rs:266` (+ presentation/spreadsheet) | Confirm-before-delete dialog (delete is immediate) | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index a5a551db..f8847701 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -118,7 +118,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). |---|---|---|---| | 4b.1 | `3b-3` | Finish cross-page navigation: left/right at page edges; `page_index` recompute after split/merge (currently `None`). | M | | 4b.2 | `formatting` | Multi-block-selection formatting (today clamped to the focus paragraph) — `editor_formatting.rs:106`. | M | -| 4b.3 | `undo-dirty` | Saved-vs-undo-stack clean tracking. **Depends on Save existing** — sequence after the Save/Save-As outcome of Phase 0 (F-findings). Also the natural moment to do Spec 01's typed `SaveError` residual. | M | +| 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Remaining tail:** Spec 01's typed `SaveError` residual; disabling the ribbon Save button when clean (needs the dirty flag threaded into the ribbon). | M | | 4b.4 | `nested-nav` | Sibling-path navigation inside cell/note bodies (`navigation.rs:138,174`). | S | | 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | | 4b.6 | F3c + F1 residual (audit §9) | Dirty-work protection: confirm-before-close for dirty tabs in loki-text (`shell.rs:101-145` discards the stashed session silently) and save-prompt/retention on presentation tab switch (`editor_inner.rs:50-57`). | S–M | diff --git a/loki-text/src/editing/mod.rs b/loki-text/src/editing/mod.rs index a7bf3f03..2d0efbf8 100644 --- a/loki-text/src/editing/mod.rs +++ b/loki-text/src/editing/mod.rs @@ -14,6 +14,7 @@ pub mod hit_test; pub mod navigation; pub mod reflow_nav; pub mod relayout; +pub mod saved_state; pub mod spell; pub mod state; pub mod touch; diff --git a/loki-text/src/editing/saved_state.rs b/loki-text/src/editing/saved_state.rs new file mode 100644 index 00000000..02d201a2 --- /dev/null +++ b/loki-text/src/editing/saved_state.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Saved-state tracking over the undo stack (plan 4b.3, `undo-dirty`). +//! +//! The tab dirty indicator needs to answer "does the live document match the +//! file on disk?". A generation counter cannot: it only moves forward, so +//! undoing back to the saved state still reads as dirty. This module tracks +//! the *undo-stack depth* of the last save instead, giving the classic +//! clean-index semantics (Qt `QUndoStack::setClean` and friends): +//! +//! - saving records the current depth as the clean point; +//! - undo/redo moving the stack back to that depth means the document again +//! equals the file — **clean**; +//! - a *fresh edit* made below the clean depth truncates the redo stack that +//! led back to it, so the saved state becomes permanently **unreachable** +//! (dirty until the next save). +//! +//! The tracker mirrors the stack depth via the [`loro::UndoManager`] +//! `on_push`/`on_pop` hooks. The discriminator for "fresh edit vs. redo +//! replay" (both push onto the undo stack) is loro's third `on_push` +//! argument: a fresh local edit passes `Some(DiffEvent)`, while the pushes +//! performed *inside* `undo()`/`redo()` pass `None`. +//! +//! Two usage invariants keep that discriminator sound (both already hold in +//! this crate — see `post_mutation_sync`): +//! +//! 1. every mutation is committed before `undo()`/`redo()`/save runs, so +//! loro's internal `record_new_checkpoint` calls never coalesce pending +//! ops into an item (which would also arrive with `None`); +//! 2. no merge interval or undo grouping is configured, so every edit pushes +//! its own item and the clean depth can never end up *inside* an item. + +use std::sync::{Arc, Mutex}; + +use loro::{UndoItemMeta, UndoOrRedo}; + +#[cfg(test)] +#[path = "saved_state_tests.rs"] +mod tests; + +/// Depth bookkeeping shared between the editor and the hooks installed on +/// the paired [`loro::UndoManager`]. +#[derive(Debug)] +struct Tracker { + /// Undo-stack depth at the last save; `None` = the saved state is no + /// longer reachable by undo/redo. + saved_depth: Option, + /// Mirror of the undo-stack depth. + depth: usize, +} + +impl Tracker { + /// A fresh local edit pushed a new undo item. An edit at or below the + /// clean depth cleared the redo path back to the saved state. + fn on_fresh_edit(&mut self) { + self.depth += 1; + if let Some(d) = self.saved_depth + && self.depth <= d + { + self.saved_depth = None; + } + } +} + +/// Cloneable handle to one document's saved-state tracker. +/// +/// [`attach`](Self::attach) installs the mirroring hooks on the document's +/// `UndoManager`; the handle then travels with the editor signals (and the +/// tab's stashed `DocSession`) so the dirty indicator can query +/// [`is_clean`](Self::is_clean) at any time. +#[derive(Clone, Debug)] +pub struct SavedStateHandle(Arc>); + +impl SavedStateHandle { + /// A tracker for a freshly loaded document: depth 0 is the clean point + /// (the document matches what was read from disk). + #[must_use] + pub fn new() -> Self { + Self(Arc::new(Mutex::new(Tracker { + saved_depth: Some(0), + depth: 0, + }))) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Tracker> { + self.0.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Installs the `on_push`/`on_pop` mirroring hooks. Call exactly once per + /// `UndoManager` (a replacement manager needs a fresh handle). + pub fn attach(&self, um: &mut loro::UndoManager) { + let push_state = Arc::clone(&self.0); + um.set_on_push(Some(Box::new(move |kind, _span, event| { + if kind == UndoOrRedo::Undo { + let mut t = push_state.lock().unwrap_or_else(|e| e.into_inner()); + if event.is_some() { + t.on_fresh_edit(); + } else { + // redo() replaying an item back onto the undo stack — + // the path to the saved state is preserved. + t.depth += 1; + } + } + UndoItemMeta::default() + }))); + let pop_state = Arc::clone(&self.0); + um.set_on_pop(Some(Box::new(move |kind, _span, _meta| { + if kind == UndoOrRedo::Undo { + // undo() popping the undo stack (including no-op items it + // pops while searching for an effective one). + let mut t = pop_state.lock().unwrap_or_else(|e| e.into_inner()); + t.depth = t.depth.saturating_sub(1); + } + }))); + } + + /// Records the current depth as the clean point (call on save success). + pub fn mark_saved(&self) { + let mut t = self.lock(); + t.saved_depth = Some(t.depth); + } + + /// Whether the document currently equals the last-saved state. + #[must_use] + pub fn is_clean(&self) -> bool { + let t = self.lock(); + t.saved_depth == Some(t.depth) + } +} + +impl Default for SavedStateHandle { + fn default() -> Self { + Self::new() + } +} diff --git a/loki-text/src/editing/saved_state_tests.rs b/loki-text/src/editing/saved_state_tests.rs new file mode 100644 index 00000000..67e26dda --- /dev/null +++ b/loki-text/src/editing/saved_state_tests.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Integration tests against a real `loro::UndoManager`, locking the loro +//! behaviours the tracker relies on (fresh edits arrive with `Some(event)`, +//! undo/redo internal pushes with `None`). + +use loro::{LoroDoc, UndoManager}; + +use super::SavedStateHandle; + +fn doc_and_manager() -> (LoroDoc, UndoManager, SavedStateHandle) { + let doc = LoroDoc::new(); + let mut um = UndoManager::new(&doc); + let tracker = SavedStateHandle::new(); + tracker.attach(&mut um); + (doc, um, tracker) +} + +fn type_text(doc: &LoroDoc, s: &str) { + let text = doc.get_text("t"); + let end = text.len_unicode(); + text.insert(end, s).unwrap(); + doc.commit(); +} + +#[test] +fn freshly_loaded_document_is_clean() { + let (_doc, _um, tracker) = doc_and_manager(); + assert!(tracker.is_clean()); +} + +#[test] +fn an_edit_dirties_and_undo_restores_clean() { + let (doc, mut um, tracker) = doc_and_manager(); + type_text(&doc, "a"); + assert!(!tracker.is_clean()); + um.undo().unwrap(); + assert!(tracker.is_clean(), "undo back to the loaded state is clean"); + um.redo().unwrap(); + assert!(!tracker.is_clean(), "redoing the edit is dirty again"); + um.undo().unwrap(); + assert!(tracker.is_clean()); +} + +#[test] +fn save_moves_the_clean_point() { + let (doc, mut um, tracker) = doc_and_manager(); + type_text(&doc, "a"); + type_text(&doc, "b"); + tracker.mark_saved(); + assert!(tracker.is_clean()); + type_text(&doc, "c"); + assert!(!tracker.is_clean()); + um.undo().unwrap(); + assert!(tracker.is_clean(), "undo back to the save point is clean"); + um.undo().unwrap(); + assert!(!tracker.is_clean(), "undoing past the save point is dirty"); + um.redo().unwrap(); + assert!( + tracker.is_clean(), + "redo forward to the save point is clean" + ); +} + +#[test] +fn saving_while_undone_makes_that_depth_the_clean_point() { + let (doc, mut um, tracker) = doc_and_manager(); + type_text(&doc, "a"); + type_text(&doc, "b"); + um.undo().unwrap(); + tracker.mark_saved(); // save the "a" state with a live redo stack + assert!(tracker.is_clean()); + um.redo().unwrap(); // "ab" differs from the file + assert!(!tracker.is_clean()); + um.undo().unwrap(); + assert!(tracker.is_clean()); +} + +#[test] +fn editing_below_the_save_point_makes_it_unreachable() { + let (doc, mut um, tracker) = doc_and_manager(); + type_text(&doc, "a"); + type_text(&doc, "b"); + tracker.mark_saved(); // clean at depth 2 ("ab") + um.undo().unwrap(); // depth 1 ("a") + type_text(&doc, "X"); // truncates the redo path back to "ab" + assert!( + !tracker.is_clean(), + "\"aX\" at the saved depth is not the saved state" + ); + // No amount of undo/redo can reach the saved state now. + um.undo().unwrap(); + assert!(!tracker.is_clean()); + um.redo().unwrap(); + assert!(!tracker.is_clean()); + um.undo().unwrap(); + um.undo().unwrap(); + assert!(!tracker.is_clean()); + // Only a new save re-establishes a clean point. + tracker.mark_saved(); + assert!(tracker.is_clean()); +} + +#[test] +fn a_replacement_manager_starts_clean_with_a_fresh_handle() { + // Mirrors the post-save compaction swap: new doc, new manager, new + // tracker — the save point is depth 0 of the fresh stack. + let (doc, _um, _old_tracker) = doc_and_manager(); + type_text(&doc, "history"); + let fresh = LoroDoc::new(); + let mut um2 = UndoManager::new(&fresh); + let tracker2 = SavedStateHandle::new(); + tracker2.attach(&mut um2); + assert!(tracker2.is_clean()); + type_text(&fresh, "z"); + assert!(!tracker2.is_clean()); + um2.undo().unwrap(); + assert!(tracker2.is_clean()); +} diff --git a/loki-text/src/routes/editor/editor_compact.rs b/loki-text/src/routes/editor/editor_compact.rs index 5b691b4a..ea4ec56d 100644 --- a/loki-text/src/routes/editor/editor_compact.rs +++ b/loki-text/src/routes/editor/editor_compact.rs @@ -33,6 +33,7 @@ const COMPACT_THRESHOLD_OPS: usize = 20_000; pub(super) fn compact_after_save( mut loro_doc: Signal>, mut undo_manager: Signal>, + mut saved_state: Signal, mut can_undo: Signal, mut can_redo: Signal, doc_state: &Arc>, @@ -50,9 +51,13 @@ pub(super) fn compact_after_save( match loki_doc_model::loro_bridge::compact_history(&doc) { Ok(fresh) => { // Everything bound to the old doc instance is recreated: the - // undo manager restarts at the save point, and the incremental - // reader re-seeds from the new doc on the next edit. - let um = loro::UndoManager::new(&fresh); + // undo manager restarts at the save point, the clean-checkpoint + // tracker restarts at depth 0 (= the just-saved state), and the + // incremental reader re-seeds from the new doc on the next edit. + let mut um = loro::UndoManager::new(&fresh); + let tracker = crate::editing::saved_state::SavedStateHandle::new(); + tracker.attach(&mut um); + saved_state.set(tracker); loro_doc.set(Some(fresh)); undo_manager.set(Some(um)); can_undo.set(false); diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index 325470d5..8fc001b8 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -45,7 +45,7 @@ use super::editor_path_sync::{ use super::editor_publish::{publish_panel, publish_tab_content}; use super::editor_ribbon::write_tab_content; use super::editor_ribbon_insert::insert_tab_content; -use super::editor_save::{export_document_to_token, save_document_to_path}; +use super::editor_save::save_document_to_path; use super::editor_save_banner::save_banner; use super::editor_spell::SpellMenu; use super::editor_state::{EditorState, use_editor_state}; @@ -54,16 +54,9 @@ use super::editor_style_catalog::available_font_families; use super::editor_style_editor::style_editor_panel; use crate::error::LoadError; use crate::new_document::is_untitled; -use crate::recent_documents::RecentDocuments; -use crate::routes::Route; use crate::sessions::DocSessions; use crate::tabs::OpenTab; -use crate::utils::display_title_from_path; use loki_app_shell::spell::SpellService; -use loki_file_access::{FilePicker, SaveOptions}; - -/// MIME type used when saving documents (DOCX is the only writable format). -const DOCX_MIME: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; // EditorMode removed — the editor is always in edit mode when a document is // open. Distraction-free reading is handled by the View ribbon tab (future @@ -110,6 +103,7 @@ pub(super) fn EditorInner(path: String) -> Element { mut superscript_active, mut subscript_active, mut undo_manager, + mut saved_state, can_undo, can_redo, is_style_picker_open, @@ -124,7 +118,6 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Tab/recents context for Save As and the unsaved-changes indicator ──── let mut tabs = use_context::>>(); - let recent_docs = use_context::>(); // Spell-check service (provided at the app root). Drives the right-click // suggestions panel and the language picker. let spell_service = use_context::(); @@ -178,6 +171,7 @@ pub(super) fn EditorInner(path: String) -> Element { editing_style_draft, save_message, baseline_gen, + saved_state, }; restore_session(session, &doc_state_restore, &mut sig); } @@ -210,6 +204,7 @@ pub(super) fn EditorInner(path: String) -> Element { editing_style_draft, save_message, baseline_gen, + saved_state, }; stash_outgoing(&path, &doc_state_drop, &mut sessions_at_drop, &mut sig); }); @@ -238,6 +233,7 @@ pub(super) fn EditorInner(path: String) -> Element { editing_style_draft, save_message, baseline_gen, + saved_state, }, ); @@ -290,8 +286,6 @@ pub(super) fn EditorInner(path: String) -> Element { } }); - let navigator = use_navigator(); - // ── Loro bridge: initialise CRDT once the document is loaded ───────────── // // The first paginated layout is a CPU-heavy pass (tens of ms on a multi-page @@ -352,7 +346,12 @@ pub(super) fn EditorInner(path: String) -> Element { match document_to_loro(&doc) { Ok(l_doc) => { - let um = loro::UndoManager::new(&l_doc); + let mut um = loro::UndoManager::new(&l_doc); + // Pair a fresh clean-checkpoint tracker with the fresh + // undo manager (depth 0 = the on-disk state). + let tracker = crate::editing::saved_state::SavedStateHandle::new(); + tracker.attach(&mut um); + saved_state.set(tracker); loro_doc.set(Some(l_doc)); undo_manager.set(Some(um)); @@ -459,14 +458,17 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Unsaved-changes (dirty) tracking ───────────────────────────────────── // // The tab shows a dirty indicator when the live document generation differs - // from the clean baseline captured at load/save. Untitled documents are - // always dirty until the first Save As. Runs whenever the cursor's mirrored - // generation, the path, or the baseline changes. + // from the clean baseline captured at load/save — unless the undo-stack + // clean checkpoint says the document is back at its saved state (undoing + // to the save point clears dirty; plan 4b.3). Untitled documents are + // always dirty until the first Save As. Runs whenever the cursor's + // mirrored generation, the path, or the baseline changes. use_effect(move || { let live_gen = cursor_state.read().document_generation; let path = path_signal(); let base = baseline_gen(); - let dirty = is_untitled(&path) || live_gen != base; + let undo_clean = saved_state.read().is_clean(); + let dirty = is_untitled(&path) || (live_gen != base && !undo_clean); let mut t = tabs.write(); if let Some(tab) = t.iter_mut().find(|tab| tab.path == path) && tab.is_dirty != dirty @@ -476,61 +478,14 @@ pub(super) fn EditorInner(path: String) -> Element { }); // ── Save As ────────────────────────────────────────────────────────────── - // - // Picks a destination via the platform save dialog, exports DOCX to it, then - // repoints the current tab at the new file and records it in recents. This - // is the only way to persist an untitled document. - let doc_state_saveas = Arc::clone(&doc_state); - let save_as = use_callback(move |_: ()| { - let doc_state = Arc::clone(&doc_state_saveas); - let mut tabs = tabs; - let mut recent = recent_docs; - let mut save_message = save_message; - let mut baseline_gen = baseline_gen; - let nav = navigator; - let cur_path = path_signal.peek().clone(); - let suggested = { - let stem = display_title_from_path(&cur_path); - format!("{stem}.docx") - }; - spawn(async move { - let picker = FilePicker::new(); - let opts = SaveOptions { - mime_type: Some(DOCX_MIME.to_string()), - suggested_name: Some(suggested), - }; - match picker.pick_file_to_save(opts).await { - Ok(Some(token)) => match export_document_to_token(&token, &doc_state) { - Ok(()) => { - let new_path = token.serialize(); - let new_title = display_title_from_path(&new_path); - { - let mut t = tabs.write(); - if let Some(tab) = t.iter_mut().find(|tab| tab.path == cur_path) { - tab.path = new_path.clone(); - tab.title = new_title.clone(); - tab.is_dirty = false; - } - } - recent.write().record(new_path.clone(), new_title); - recent.read().save(); - save_message.set(Some(fl!("editor-save-success"))); - // Navigate to the saved file; the editor reloads it and - // re-establishes a clean baseline. - baseline_gen.set(0); - nav.push(Route::Editor { path: new_path }); - } - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); - } - }, - Ok(None) => { /* user cancelled — no-op */ } - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); - } - } - }); - }); + // Extracted flow (editor_save_callbacks): picks a destination, exports + // DOCX, repoints the tab, records recents, and navigates to the new path. + let save_as = super::editor_save_callbacks::use_save_as_callback( + Arc::clone(&doc_state), + save_message, + baseline_gen, + path_signal, + ); // ── Save as Template (.dotx) ─────────────────────────────────────────────── // Self-contained save flow — extracted to keep this file under its ceiling. @@ -569,11 +524,18 @@ pub(super) fn EditorInner(path: String) -> Element { let msg = match save_document_to_path(&path, &doc_state_savereq) { Ok(()) => { baseline_gen.set(cursor_state.peek().document_generation); + // Record the undo-stack clean checkpoint: undoing back to + // this depth means the document equals the file again. + if let Some(um) = undo_manager.write().as_mut() { + let _ = um.record_new_checkpoint(); + } + saved_state.peek().mark_saved(); // The file is now the durable state — bound the CRDT history // memory (memory-audit Finding 6; see editor_compact.rs). super::editor_compact::compact_after_save( loro_doc, undo_manager, + saved_state, can_undo, can_redo, &doc_state_savereq, diff --git a/loki-text/src/routes/editor/editor_path_sync.rs b/loki-text/src/routes/editor/editor_path_sync.rs index 2cf041e2..44057864 100644 --- a/loki-text/src/routes/editor/editor_path_sync.rs +++ b/loki-text/src/routes/editor/editor_path_sync.rs @@ -16,6 +16,7 @@ use dioxus::prelude::*; use super::editor_state::StyleDraft; use crate::editing::cursor::CursorState; +use crate::editing::saved_state::SavedStateHandle; use crate::editing::state::DocumentState; use crate::sessions::{DocSession, DocSessions}; @@ -34,6 +35,7 @@ pub(super) struct PathSyncSignals { pub editing_style_draft: Signal>, pub save_message: Signal>, pub baseline_gen: Signal, + pub saved_state: Signal, } /// Synchronises `path_signal` with the `path` prop. On change, stashes the @@ -115,6 +117,7 @@ pub(super) fn stash_outgoing( page_height_px, cursor: sig.cursor_state.peek().clone(), baseline_gen: *sig.baseline_gen.peek(), + saved_state: sig.saved_state.peek().clone(), can_undo: *sig.can_undo.peek(), can_redo: *sig.can_redo.peek(), }, @@ -148,6 +151,7 @@ pub(super) fn restore_session( sig.can_undo.set(session.can_undo); sig.can_redo.set(session.can_redo); sig.baseline_gen.set(session.baseline_gen); + sig.saved_state.set(session.saved_state); } /// Reset all per-document state ahead of a fresh `load_document` pass. @@ -167,4 +171,5 @@ fn reset_for_fresh_load(doc_state: &Arc>, sig: &mut PathSyn sig.current_page.set(1); sig.can_undo.set(false); sig.can_redo.set(false); + sig.saved_state.set(SavedStateHandle::new()); } diff --git a/loki-text/src/routes/editor/editor_save_callbacks.rs b/loki-text/src/routes/editor/editor_save_callbacks.rs index 22ce1011..d9ebd504 100644 --- a/loki-text/src/routes/editor/editor_save_callbacks.rs +++ b/loki-text/src/routes/editor/editor_save_callbacks.rs @@ -2,9 +2,8 @@ //! Save-dialog callbacks extracted from `editor_inner` (an oversized file). //! -//! Currently hosts the **Save as Template** flow, which is self-contained (it -//! does not repoint the tab or navigate). Save As stays in `editor_inner` -//! because it also touches tab/recents/navigation state. +//! Hosts the **Save As** flow (repoints the tab, records recents, navigates +//! to the new path) and the self-contained **Save as Template** flow. use std::sync::{Arc, Mutex}; @@ -12,13 +11,85 @@ use dioxus::prelude::*; use loki_file_access::{FilePicker, SaveOptions}; use loki_i18n::fl; -use super::editor_save::export_template_to_token; +use super::editor_save::{export_document_to_token, export_template_to_token}; use crate::editing::state::DocumentState; +use crate::recent_documents::RecentDocuments; +use crate::routes::Route; +use crate::tabs::OpenTab; use crate::utils::display_title_from_path; +/// MIME type used by the Save As flow (Word `.docx`). +const DOCX_MIME: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + /// MIME type used by the "Save as Template" flow (Word `.dotx`). const DOTX_MIME: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.template"; +/// Builds the Save As callback: picks a destination via the platform save +/// dialog, exports DOCX to it, then repoints the current tab at the new file, +/// records it in recents, and navigates to the new path (the editor reloads +/// it and re-establishes a clean baseline). This is the only way to persist +/// an untitled document. +pub(super) fn use_save_as_callback( + doc_state: Arc>, + save_message: Signal>, + baseline_gen: Signal, + path_signal: Signal, +) -> Callback<()> { + let tabs = use_context::>>(); + let recent_docs = use_context::>(); + let navigator = use_navigator(); + use_callback(move |_: ()| { + let doc_state = Arc::clone(&doc_state); + let mut tabs = tabs; + let mut recent = recent_docs; + let mut save_message = save_message; + let mut baseline_gen = baseline_gen; + let nav = navigator; + let cur_path = path_signal.peek().clone(); + let suggested = { + let stem = display_title_from_path(&cur_path); + format!("{stem}.docx") + }; + spawn(async move { + let picker = FilePicker::new(); + let opts = SaveOptions { + mime_type: Some(DOCX_MIME.to_string()), + suggested_name: Some(suggested), + }; + match picker.pick_file_to_save(opts).await { + Ok(Some(token)) => match export_document_to_token(&token, &doc_state) { + Ok(()) => { + let new_path = token.serialize(); + let new_title = display_title_from_path(&new_path); + { + let mut t = tabs.write(); + if let Some(tab) = t.iter_mut().find(|tab| tab.path == cur_path) { + tab.path = new_path.clone(); + tab.title = new_title.clone(); + tab.is_dirty = false; + } + } + recent.write().record(new_path.clone(), new_title); + recent.read().save(); + save_message.set(Some(fl!("editor-save-success"))); + // Navigate to the saved file; the editor reloads it and + // re-establishes a clean baseline. + baseline_gen.set(0); + nav.push(Route::Editor { path: new_path }); + } + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + } + }, + Ok(None) => { /* user cancelled — no-op */ } + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + } + } + }); + }) +} + /// Builds the "Save as Template" callback: exports the current document as a /// Word template (`.dotx`) to a picked destination. Unlike Save As it does not /// repoint the tab — the template is a separate artifact. diff --git a/loki-text/src/routes/editor/editor_state.rs b/loki-text/src/routes/editor/editor_state.rs index d526a684..2368b211 100644 --- a/loki-text/src/routes/editor/editor_state.rs +++ b/loki-text/src/routes/editor/editor_state.rs @@ -15,6 +15,7 @@ use loki_renderer::ViewMode; use super::editor_scrollbar::{CanvasMounted, ScrollMetrics, ThumbDrag}; use crate::editing::cursor::CursorState; +use crate::editing::saved_state::SavedStateHandle; use crate::editing::state::DocumentState; use crate::editing::touch::TouchInteractionState; @@ -114,14 +115,13 @@ pub(super) struct EditorState { pub superscript_active: Signal, pub subscript_active: Signal, /// Loro undo manager — `None` until the document loads. - /// - /// // TODO(undo-dirty): the tab dirty indicator already tracks - /// saved-vs-edited via a generation baseline (`editor_inner.rs`, - /// `baseline_gen`), but the undo stack itself has no clean checkpoint. - /// Call `UndoManager::record_new_checkpoint()` on Save so undoing back - /// to the saved state clears dirty and the ribbon Save button can be - /// disabled when there is nothing to save. pub undo_manager: Signal>, + /// Clean-checkpoint tracker paired with `undo_manager` (plan 4b.3): save + /// records the undo-stack depth, so undoing back to the saved state + /// clears the tab's dirty indicator. Replaced together with the manager + /// (load, post-save compaction swap) and stashed with it in the tab's + /// `DocSession`. + pub saved_state: Signal, /// Whether Ctrl+Z is currently applicable (derived from `undo_manager`). pub can_undo: Signal, /// Whether Ctrl+Y / Ctrl+Shift+Z is currently applicable. @@ -191,6 +191,7 @@ pub(super) fn use_editor_state() -> EditorState { superscript_active: use_signal(|| false), subscript_active: use_signal(|| false), undo_manager: use_signal(|| None), + saved_state: use_signal(SavedStateHandle::new), can_undo: use_signal(|| false), can_redo: use_signal(|| false), is_style_picker_open: use_signal(|| false), diff --git a/loki-text/src/sessions.rs b/loki-text/src/sessions.rs index 4c53612f..0cc841a6 100644 --- a/loki-text/src/sessions.rs +++ b/loki-text/src/sessions.rs @@ -45,6 +45,8 @@ pub struct DocSession { pub cursor: CursorState, /// Document generation considered clean (matches the on-disk file). pub baseline_gen: u64, + /// Undo-stack clean-checkpoint tracker paired with `undo_manager`. + pub saved_state: crate::editing::saved_state::SavedStateHandle, /// Ribbon undo/redo button state at stash time. pub can_undo: bool, /// Ribbon undo/redo button state at stash time. diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index 2fc7ab79..d7e94202 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -12,7 +12,7 @@ 1073 loki-ooxml/src/docx/write/document.rs 978 loki-layout/src/resolve.rs 948 loki-vello/src/scene.rs -870 loki-text/src/routes/editor/editor_inner.rs +833 loki-text/src/routes/editor/editor_inner.rs 741 loki-layout/src/flow_para.rs 730 loki-ooxml/src/docx/mapper/inline.rs 651 loki-odf/src/package.rs From 0d5c7c4a1ccd3eaa8994f930fbb664de646011a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:14:46 +0000 Subject: [PATCH 17/27] Confirmation dialogs: dirty-tab close and recents file delete (all apps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 4b.6 (F3c) and 4c.1 of the deferred-features plan: closing a dirty tab silently discarded its unsaved edits (and, in loki-text, its stashed session), and the recents "Delete file" menu action deleted the file immediately with no confirmation. appthere-ui: - New AtConfirmDialog primitive: a blocking confirmation overlay for destructive actions — absolute full-area backdrop (dim + click-blocks; backdrop click cancels) with a centred card, per the confirmed-working Blitz absolute-positioning pattern (position: fixed collapses in stylo_taffy). Both action buttons meet the 44 px WCAG 2.5.8 touch target; danger styling on the confirm button. Mounting contract (positioned ancestor) documented on the module. Shells (loki-text, loki-spreadsheet, loki-presentation): - on_tab_close now routes dirty tabs through the dialog; the actual close logic is extracted to a close_tab helper shared by the handler and the dialog's confirm. Shell roots gain position: relative to anchor the overlay. Homes (all three apps): - The recents Delete action only *requests* deletion (captures path + title); AtConfirmDialog performs the delete-on-confirm. The pre-existing behaviour (delete via capability token, surface errors, close matching tab, drop recents entry) is unchanged. - home.rs helper functions split into home_util.rs in each app to stay inside the 300-line ceiling ratchet. i18n: new shell-close-dirty-* and home-delete-confirm-* keys (en-US). Remaining 4b.6 tail (noted in the plan): the presentation editor still discards in-memory edits on tab *switch* — it needs a loki-text-style session stash. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- appthere-ui/src/components/confirm_dialog.rs | 180 +++++++++++++++++++ appthere-ui/src/components/mod.rs | 2 + appthere-ui/src/lib.rs | 7 +- docs/deferred-features-audit-2026-07-04.md | 4 +- docs/deferred-features-plan-2026-07-04.md | 4 +- loki-i18n/i18n/en-US/home.ftl | 6 + loki-i18n/i18n/en-US/shell.ftl | 6 + loki-presentation/src/routes/home.rs | 130 +++++++------- loki-presentation/src/routes/home_util.rs | 60 +++++++ loki-presentation/src/routes/mod.rs | 1 + loki-presentation/src/routes/shell.rs | 94 +++++++--- loki-spreadsheet/src/routes/home.rs | 131 +++++++------- loki-spreadsheet/src/routes/home_util.rs | 61 +++++++ loki-spreadsheet/src/routes/mod.rs | 1 + loki-spreadsheet/src/routes/shell.rs | 94 +++++++--- loki-text/src/routes/home.rs | 137 ++++++-------- loki-text/src/routes/home_util.rs | 84 +++++++++ loki-text/src/routes/mod.rs | 1 + loki-text/src/routes/shell.rs | 125 ++++++++----- 19 files changed, 807 insertions(+), 321 deletions(-) create mode 100644 appthere-ui/src/components/confirm_dialog.rs create mode 100644 loki-presentation/src/routes/home_util.rs create mode 100644 loki-spreadsheet/src/routes/home_util.rs create mode 100644 loki-text/src/routes/home_util.rs diff --git a/appthere-ui/src/components/confirm_dialog.rs b/appthere-ui/src/components/confirm_dialog.rs new file mode 100644 index 00000000..2e7ef18c --- /dev/null +++ b/appthere-ui/src/components/confirm_dialog.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! `AtConfirmDialog` — a blocking confirmation overlay for destructive or +//! data-losing actions (delete a file, close a dirty tab). +//! +//! Rendered as a `position: absolute` full-area backdrop with a centred card, +//! so the **mounting parent (or an ancestor) must be `position: relative` and +//! span the area to dim** — the same confirmed-working Blitz pattern as the +//! editor's floating spelling menu (`position: fixed` collapses to `absolute` +//! in stylo_taffy and must not be used). +//! +//! Mount it conditionally at a component boundary (ADR-0013): +//! ```rust,ignore +//! {pending().then(|| rsx! { +//! AtConfirmDialog { +//! title: fl!("home-delete-confirm-title"), +//! message: fl!("home-delete-confirm-message", title = name), +//! confirm_label: fl!("home-delete-confirm-confirm"), +//! cancel_label: fl!("home-delete-confirm-cancel"), +//! danger: true, +//! on_confirm: move |_| { /* do it */ pending.set(None); }, +//! on_cancel: move |_| pending.set(None), +//! } +//! })} +//! ``` +//! +//! Clicking the backdrop cancels (same as the Cancel button). Both buttons +//! meet the 44×44 logical-pixel minimum touch target (WCAG 2.5.8) via +//! `min-height: TOUCH_MIN` and horizontal padding. + +use dioxus::prelude::*; + +use crate::tokens::colors::{ + COLOR_ACCENT_PRIMARY, COLOR_BORDER_CHROME, COLOR_STATUS_ERROR_BORDER, COLOR_SURFACE_1, + COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, +}; +use crate::tokens::spacing::{RADIUS_MD, SPACE_2, SPACE_3, SPACE_4, TOUCH_MIN}; +use crate::tokens::typography::{ + FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_MD, FONT_WEIGHT_SEMIBOLD, +}; + +/// Width of the dialog card in logical pixels — narrow enough for Compact +/// portrait phones (the backdrop centring keeps it on-screen either way). +const DIALOG_WIDTH_PX: f32 = 320.0; + +/// Confirmation dialog props. All display strings are props (i18n-agnostic). +#[derive(Props, Clone, PartialEq)] +pub struct AtConfirmDialogProps { + /// Short dialog heading (e.g. "Delete file"). + pub title: String, + /// One- or two-sentence body explaining what will happen. + pub message: String, + /// Label of the confirming (destructive) button. + pub confirm_label: String, + /// Label of the cancelling (safe) button. + pub cancel_label: String, + /// Style the confirm button as destructive (error border/text). Defaults + /// to `true` — this dialog exists for destructive confirmations. + #[props(default = true)] + pub danger: bool, + /// Invoked when the user confirms the action. + pub on_confirm: EventHandler<()>, + /// Invoked when the user cancels (button or backdrop click). + pub on_cancel: EventHandler<()>, +} + +/// Blocking confirmation overlay. See the module docs for the mounting +/// contract (positioned ancestor) and touch-target guarantees. +/// +/// Touch targets: both action buttons are at least 44×44 logical pixels +/// (`min-height: 44px`, padded width well past 44px). +#[component] +pub fn AtConfirmDialog(props: AtConfirmDialogProps) -> Element { + let confirm_border = if props.danger { + COLOR_STATUS_ERROR_BORDER + } else { + COLOR_ACCENT_PRIMARY + }; + let button_base = format!( + "min-height: {th}px; box-sizing: border-box; \ + padding: {py}px {px}px; border-radius: {r}px; \ + font-family: {font}; font-size: {fs}px; font-weight: {fw}; \ + display: flex; align-items: center; justify-content: center;", + th = TOUCH_MIN, + py = SPACE_2, + px = SPACE_4, + r = RADIUS_MD, + font = FONT_FAMILY_UI, + fs = FONT_SIZE_BODY, + fw = FONT_WEIGHT_SEMIBOLD, + ); + + rsx! { + // Backdrop: dims and click-blocks the area behind the dialog; + // clicking it is a cancel. Centres the card via flex. + div { + style: "position: absolute; top: 0; left: 0; width: 100%; height: 100%; \ + z-index: 2000; background: rgba(0, 0, 0, 0.45); \ + display: flex; align-items: center; justify-content: center;", + role: "presentation", + onclick: move |_| props.on_cancel.call(()), + + // The dialog card. Clicks inside must not fall through to the + // backdrop's cancel handler. + div { + style: format!( + "width: {w}px; max-width: 90%; box-sizing: border-box; \ + display: flex; flex-direction: column; gap: {gap}px; \ + background: {bg}; border: 1px solid {border}; \ + border-radius: {r}px; padding: {pad}px; \ + font-family: {font}; color: {fg};", + w = DIALOG_WIDTH_PX, + gap = SPACE_3, + bg = COLOR_SURFACE_1, + border = COLOR_BORDER_CHROME, + r = RADIUS_MD, + pad = SPACE_4, + font = FONT_FAMILY_UI, + fg = COLOR_TEXT_ON_CHROME, + ), + role: "dialog", + "aria-label": props.title.clone(), + onclick: move |evt| evt.stop_propagation(), + + // Title + div { + style: format!( + "font-size: {fs}px; font-weight: {fw};", + fs = FONT_SIZE_MD, + fw = FONT_WEIGHT_SEMIBOLD, + ), + {props.title.clone()} + } + + // Message body + div { + style: format!( + "font-size: {fs}px; color: {fg};", + fs = FONT_SIZE_BODY, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + ), + {props.message.clone()} + } + + // Action row: Cancel (safe, left) then Confirm (destructive). + div { + style: format!( + "display: flex; justify-content: flex-end; gap: {gap}px;", + gap = SPACE_2, + ), + button { + style: format!( + "{button_base} background: transparent; \ + border: 1px solid {border}; color: {fg};", + border = COLOR_BORDER_CHROME, + fg = COLOR_TEXT_ON_CHROME, + ), + onclick: move |evt| { + evt.stop_propagation(); + props.on_cancel.call(()); + }, + {props.cancel_label.clone()} + } + button { + style: format!( + "{button_base} background: transparent; \ + border: 1px solid {confirm_border}; color: {confirm_border};", + ), + onclick: move |evt| { + evt.stop_propagation(); + props.on_confirm.call(()); + }, + {props.confirm_label.clone()} + } + } + } + } + } +} diff --git a/appthere-ui/src/components/mod.rs b/appthere-ui/src/components/mod.rs index 2b822cc1..2ffc7661 100644 --- a/appthere-ui/src/components/mod.rs +++ b/appthere-ui/src/components/mod.rs @@ -5,6 +5,7 @@ //! All components are application-agnostic — they must not reference any //! application-specific route enum, document model, or business logic. +pub mod confirm_dialog; pub mod document_tab; pub mod home_tab; pub mod icons; @@ -15,6 +16,7 @@ pub mod status_bar; pub mod tab_bar; pub mod title_bar; +pub use confirm_dialog::{AtConfirmDialog, AtConfirmDialogProps}; pub use document_tab::{AtDocumentTab, AtDocumentTabProps}; pub use home_tab::{AtHomeTab, AtHomeTabProps, BuiltinTemplate, RecentDocument}; pub use panel_host::{AtPanelHost, AtPanelHostProps, PanelPosture}; diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index d86db451..807468cf 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -42,9 +42,10 @@ pub use components::ribbon::{ AtRibbon, AtRibbonGroup, AtRibbonIconButton, AtRibbonSelect, RibbonTabDesc, RibbonTabIndex, }; pub use components::{ - AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, AtHomeTab, AtHomeTabProps, AtPanelHost, - AtPanelHostProps, AtStatusBar, AtStatusBarProps, AtTabBar, AtTabBarProps, AtTitleBar, - AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform, RecentDocument, + AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, + AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, AtStatusBarProps, + AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform, + RecentDocument, }; pub use responsive::{ page_fits, required_page_width, resolve_page_fit, use_breakpoint, use_provide_responsive, diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 66bae030..c42b3b6e 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -193,8 +193,8 @@ per-sub-item verdicts against HEAD `20b05a6`: | # | Original claim | Verdict | Evidence / residual | |---|---|---|---| | F1 | Presentation editor is a hardcoded demo; no load/save | **RESOLVED** (core) | Real PPTX import (`editor_load.rs:40-54` → `PptxImport`) and export with Save/Save As (`editor_save.rs`, `editor_inner.rs:87-149`); ODP is a typed `UnsupportedFormat` (deferred by MVP scope, not faked). **Residual:** in-memory edits still discarded on tab switch (`editor_inner.rs:50-57` resets `doc` on path change) — now *warned* via dirty indicator, no longer silent. | -| F2 | Recents Delete/Copy are silent no-ops (3 apps) | **RESOLVED** | `FileAccessToken::delete()` / `copy_bytes_to()` exist (`patches/loki-file-access/src/token.rs:116,132`) and all three `home.rs` handlers use them; failures surfaced via `pick_error` + `errors.ftl` keys. Remaining `TODO(ux)` confirm-dialog is tracked separately (plan 4c.1). | -| F3 | Edits lost on tab switch; dirty flag never set | **LARGELY RESOLVED** | Per-tab retention via `loki-text/src/sessions.rs` `DocSession` (stash/restore in `editor_path_sync.rs:42-154`); dirty tracks a generation baseline (`editor_inner.rs:465-476`), cleared on save. **Residual (F3c):** `shell.rs:101-145` still closes dirty tabs with no confirmation, discarding the stashed session. | +| F2 | Recents Delete/Copy are silent no-ops (3 apps) | **RESOLVED** | `FileAccessToken::delete()` / `copy_bytes_to()` exist (`patches/loki-file-access/src/token.rs:116,132`) and all three `home.rs` handlers use them; failures surfaced via `pick_error` + `errors.ftl` keys. The `TODO(ux)` confirm-dialog landed 2026-07-05 (plan 4c.1, `AtConfirmDialog`). | +| F3 | Edits lost on tab switch; dirty flag never set | **LARGELY RESOLVED** | Per-tab retention via `loki-text/src/sessions.rs` `DocSession` (stash/restore in `editor_path_sync.rs:42-154`); dirty tracks a generation baseline (`editor_inner.rs:465-476`), cleared on save. **Residual (F3c) resolved 2026-07-05** (plan 4b.6): closing a dirty tab raises an `AtConfirmDialog` confirmation in all three apps' shells. | | F4 | Untitled documents cannot be saved; no Ctrl+S | **RESOLVED** | Save As via `pick_file_to_save` (`editor_inner.rs:484-535`); Ctrl/Cmd+S bound (`editor_keydown.rs:60-67`) routing untitled → Save As. | | F5 | Settle/retier pipeline wired to dead channels | **RESOLVED (by removal)** | The pipeline was deleted, not fixed: virtualization now bounds memory by mounting only viewport-window pages (`virtualize.rs` `visible_window`, `document_view.rs:290`). The 06-10 audit's §5 claim of "downsample by viewport distance" was wrong about the mechanism — corrected there. **Residual:** `DocumentViewProps::eq` still hardwired `false` (`document_view.rs:143-147`) — now a benign over-render, capped by `PageTile`'s own `PartialEq`. | | F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom controls dead in all 3 apps; spreadsheet ribbon tab-select/collapse dead**. RESOLVED 2026-07-05: F6c-selection — typing replaces the active selection and Backspace/Delete remove it, including multi-block ranges (`loki_doc_model::delete_selection_at` composes `merge_block_at` + `delete_text_at` with pre-validation, so cross-container or table-spanning ranges are rejected untouched; wired via `editor_keydown_text.rs`; tested by `loro_selection_delete_tests.rs` + editor unit tests). STILL-OPEN: F6a hooks in conditionals/loops (`recent_files.rs:45,96,219`), F6c-clipboard (copy/cut/paste — partially gated on the unimplemented dioxus-native-dom clipboard converter), F6f synchronous save/load on UI thread. | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index f8847701..9d06a75c 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -121,14 +121,14 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Remaining tail:** Spec 01's typed `SaveError` residual; disabling the ribbon Save button when clean (needs the dirty flag threaded into the ribbon). | M | | 4b.4 | `nested-nav` | Sibling-path navigation inside cell/note bodies (`navigation.rs:138,174`). | S | | 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | -| 4b.6 | F3c + F1 residual (audit §9) | Dirty-work protection: confirm-before-close for dirty tabs in loki-text (`shell.rs:101-145` discards the stashed session silently) and save-prompt/retention on presentation tab switch (`editor_inner.rs:50-57`). | S–M | +| 4b.6 | F3c + F1 residual (audit §9) | **Dirty-close protection ✅ Done 2026-07-05:** closing a dirty tab now raises a confirmation dialog in **all three apps** (new `appthere_ui::AtConfirmDialog` overlay primitive — absolute backdrop + centred card, backdrop-cancel, 44 px touch targets; shells gain `pending_close` state + an extracted `close_tab` helper). **Remaining (F1 residual):** presentation still discards in-memory edits on tab *switch* (`editor_inner.rs:50-57` — needs a `sessions.rs`-style stash like loki-text's). | S–M | | 4b.7 | F6c + F6f (audit §9) | **Selection editing ✅ Done 2026-07-05:** typing replaces the active selection, Backspace/Delete remove it, incl. multi-block ranges — `loki_doc_model::delete_selection_at` (merge-then-delete composition, whole range pre-validated so cross-container / table-spanning selections are rejected untouched), editor wiring in `editor_keydown_text.rs` (replace-typing is one undo entry); tests: `loro_selection_delete_tests.rs` (10) + editor unit tests (7). **Remaining:** clipboard copy/cut/paste (partially gated on the unimplemented dioxus-native-dom clipboard converter), and moving save/load I/O off the UI thread (`editor_ribbon.rs:93`, `editor_load.rs:56-101`). | M | ### 4c. Shell/UX polish TODOs (§2) — batchable | Task | Topics | Detail | Effort | |---|---|---|---| -| 4c.1 | `ux` | Confirm-before-delete dialog in all three apps' Home (delete is immediate today). Small, user-data-protecting — do first in 4c. | S | +| 4c.1 | `ux` | ✅ **Done 2026-07-05** — the recents Delete menu action now only *requests* deletion; `AtConfirmDialog` performs it on confirm (all three apps' Home; `home.rs` split `home_util.rs` out to stay under the ceiling). | S | | 4c.2 | `a11y` | Expand invisible status-bar touch targets to `TOUCH_MIN` (WCAG 2.5.8 is a stated project convention). | S | | 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | | 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | diff --git a/loki-i18n/i18n/en-US/home.ftl b/loki-i18n/i18n/en-US/home.ftl index 61deafb6..0ec2f66b 100644 --- a/loki-i18n/i18n/en-US/home.ftl +++ b/loki-i18n/i18n/en-US/home.ftl @@ -59,3 +59,9 @@ home-template-portfolio = Portfolio home-template-portfolio-description = Visual design portfolio home-template-portfolio-format = PPTX home-filter-label-presentation = Presentations + +# Recents delete confirmation +home-delete-confirm-title = Delete file +home-delete-confirm-message = Permanently delete "{ $title }"? This cannot be undone. +home-delete-confirm-confirm = Delete +home-delete-confirm-cancel = Cancel diff --git a/loki-i18n/i18n/en-US/shell.ftl b/loki-i18n/i18n/en-US/shell.ftl index f7d011ff..628b3cc4 100644 --- a/loki-i18n/i18n/en-US/shell.ftl +++ b/loki-i18n/i18n/en-US/shell.ftl @@ -10,3 +10,9 @@ shell-home-tab = Home shell-tab-bar-aria = Open documents shell-new-document-aria = New document shell-close-tab-aria = Close { $title } + +# Dirty-tab close confirmation +shell-close-dirty-title = Unsaved changes +shell-close-dirty-message = "{ $title }" has unsaved changes. Close the tab and discard them? +shell-close-dirty-confirm = Discard changes +shell-close-dirty-cancel = Keep editing diff --git a/loki-presentation/src/routes/home.rs b/loki-presentation/src/routes/home.rs index 2d4b61f9..49cdbb53 100644 --- a/loki-presentation/src/routes/home.rs +++ b/loki-presentation/src/routes/home.rs @@ -2,11 +2,13 @@ //! Home screen route component for loki-presentation. -use appthere_ui::{AtHomeTab, BuiltinTemplate, RecentDocument}; +use appthere_ui::{AtConfirmDialog, AtHomeTab, BuiltinTemplate, RecentDocument}; use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, PickOptions, PickerError, SaveOptions}; use loki_i18n::fl; +use super::home_util::{close_tab_for_path, push_or_switch_tab, suggested_copy_name}; + use crate::new_document::new_blank_tab; use crate::recent_documents::RecentDocuments; use crate::routes::Route; @@ -42,47 +44,6 @@ fn make_templates() -> Vec { ] } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -fn push_or_switch_tab(mut tabs: Signal>, mut active_tab: Signal, path: String) { - let title = display_title_from_path(&path); - let existing = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = existing { - *active_tab.write() = idx + 1; - } else { - tabs.write().push(OpenTab { - title, - path, - is_dirty: false, - is_discarded: false, - }); - *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home - } -} - -/// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. -fn close_tab_for_path(mut tabs: Signal>, mut active_tab: Signal, path: &str) { - let removed = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = removed { - tabs.write().remove(idx); - // active_tab is 1-based (index 0 = Home). Reset to Home if the active - // selection pointed at or past the removed tab to avoid a stale index. - if *active_tab.read() > idx { - *active_tab.write() = 0; - } - } -} - -/// Build a " Copy." filename from a token's display name. -fn suggested_copy_name(token: &FileAccessToken) -> String { - let name = token.display_name(); - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), - _ => format!("{name} Copy"), - } -} - // ── Home ────────────────────────────────────────────────────────────────────── /// Home screen component. @@ -169,15 +130,25 @@ pub fn Home() -> Element { // ── on_recent_delete ────────────────────────────────────────────────────── // - // `path` is a serialised FileAccessToken, not a filesystem path. Decode it, - // delete the underlying file via the capability token, close any open tab - // for it, then drop the recents entry. + // Deleting a file is destructive, so the menu action only *requests* it: + // the confirmation dialog below performs the deletion on confirm (4c.1). + let mut pending_delete: Signal> = use_signal(|| None); let on_recent_delete = move |idx: usize| { - let mut err_sig = pick_error; - let Some(path) = recent_docs.read().entries.get(idx).map(|e| e.path.clone()) else { - return; - }; + let entry = recent_docs + .read() + .entries + .get(idx) + .map(|e| (e.path.clone(), e.title.clone())); + if let Some(path_and_title) = entry { + pending_delete.set(Some(path_and_title)); + } + }; + // The confirmed deletion. `path` is a serialised FileAccessToken, not a + // filesystem path: decode it, delete the underlying file via the + // capability token, close any open tab for it, then drop the recents entry. + let mut delete_recent = move |path: String| { + let mut err_sig = pick_error; match FileAccessToken::deserialize(&path) { Ok(token) => { if let Err(e) = token.delete() { @@ -190,7 +161,6 @@ pub fn Home() -> Element { } close_tab_for_path(tabs, active_tab, &path); - // TODO(ux): Add a confirmation dialog before destructive deletion. recent_docs.write().remove(&path); recent_docs.read().save(); }; @@ -263,26 +233,46 @@ pub fn Home() -> Element { .collect(); rsx! { - AtHomeTab { - templates: make_templates(), - recent_documents: recent_list, - templates_label: fl!("home-templates-heading"), - recent_label: fl!("home-recent-heading"), - browse_label: String::new(), - open_file_label: fl!("home-open-file"), - empty_recent_label: fl!("home-no-recent"), - recent_menu_aria_label: fl!("home-recent-menu-aria"), - recent_remove_label: fl!("home-recent-menu-remove"), - recent_delete_label: fl!("home-recent-menu-delete"), - recent_open_copy_label: fl!("home-recent-menu-open-copy"), - pick_error: pick_error, - on_template_select: on_template_select, - on_browse_templates: |_| {}, - on_recent_open: on_recent_open, - on_open_file: on_open_file, - on_recent_remove: on_recent_remove, - on_recent_delete: on_recent_delete, - on_recent_open_copy: on_recent_open_copy, + // position: relative anchors the AtConfirmDialog overlay over the + // home area (AtHomeTab sizes itself to the viewport minus tab bar). + div { + style: "position: relative;", + AtHomeTab { + templates: make_templates(), + recent_documents: recent_list, + templates_label: fl!("home-templates-heading"), + recent_label: fl!("home-recent-heading"), + browse_label: String::new(), + open_file_label: fl!("home-open-file"), + empty_recent_label: fl!("home-no-recent"), + recent_menu_aria_label: fl!("home-recent-menu-aria"), + recent_remove_label: fl!("home-recent-menu-remove"), + recent_delete_label: fl!("home-recent-menu-delete"), + recent_open_copy_label: fl!("home-recent-menu-open-copy"), + pick_error: pick_error, + on_template_select: on_template_select, + on_browse_templates: |_| {}, + on_recent_open: on_recent_open, + on_open_file: on_open_file, + on_recent_remove: on_recent_remove, + on_recent_delete: on_recent_delete, + on_recent_open_copy: on_recent_open_copy, + } + + // ── Delete confirmation (ADR-0013 boundary mount) ───────────────── + {pending_delete.read().clone().map(|(path, title)| rsx! { + AtConfirmDialog { + title: fl!("home-delete-confirm-title"), + message: fl!("home-delete-confirm-message", title = title), + confirm_label: fl!("home-delete-confirm-confirm"), + cancel_label: fl!("home-delete-confirm-cancel"), + on_confirm: move |_| { + pending_delete.set(None); + delete_recent(path.clone()); + }, + on_cancel: move |_| pending_delete.set(None), + } + })} } } } diff --git a/loki-presentation/src/routes/home_util.rs b/loki-presentation/src/routes/home_util.rs new file mode 100644 index 00000000..e10c881a --- /dev/null +++ b/loki-presentation/src/routes/home_util.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Tab and file-token helpers for the Home route. +//! +//! Extracted from `home.rs` to keep that file under the 300-line ceiling. + +use dioxus::prelude::*; +use loki_file_access::FileAccessToken; + +use crate::tabs::OpenTab; +use crate::utils::display_title_from_path; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +pub(super) fn push_or_switch_tab( + mut tabs: Signal>, + mut active_tab: Signal, + path: String, +) { + let title = display_title_from_path(&path); + let existing = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = existing { + *active_tab.write() = idx + 1; + } else { + tabs.write().push(OpenTab { + title, + path, + is_dirty: false, + is_discarded: false, + }); + *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home + } +} + +/// Close any open tab whose `path` matches `path`, resetting the active tab to +/// Home when the closed (or a now-shifted) tab was selected. +pub(super) fn close_tab_for_path( + mut tabs: Signal>, + mut active_tab: Signal, + path: &str, +) { + let removed = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = removed { + tabs.write().remove(idx); + // active_tab is 1-based (index 0 = Home). Reset to Home if the active + // selection pointed at or past the removed tab to avoid a stale index. + if *active_tab.read() > idx { + *active_tab.write() = 0; + } + } +} + +/// Build a " Copy." filename from a token's display name. +pub(super) fn suggested_copy_name(token: &FileAccessToken) -> String { + let name = token.display_name(); + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), + _ => format!("{name} Copy"), + } +} diff --git a/loki-presentation/src/routes/mod.rs b/loki-presentation/src/routes/mod.rs index 12a43763..1a785708 100644 --- a/loki-presentation/src/routes/mod.rs +++ b/loki-presentation/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod editor; pub mod home; +mod home_util; pub mod shell; use dioxus::prelude::*; diff --git a/loki-presentation/src/routes/shell.rs b/loki-presentation/src/routes/shell.rs index c1d3035c..3a6ed7ae 100644 --- a/loki-presentation/src/routes/shell.rs +++ b/loki-presentation/src/routes/shell.rs @@ -3,24 +3,66 @@ //! Persistent application shell wrapping all routes for loki-presentation. use appthere_ui::tokens; -use appthere_ui::{AtDocumentTabData, AtTabBar}; +use appthere_ui::{AtConfirmDialog, AtDocumentTabData, AtTabBar}; use dioxus::prelude::*; +use dioxus_router::Navigator; use loki_i18n::fl; use crate::routes::Route; use crate::tabs::OpenTab; +/// Closes the tab at 1-based tab-bar index `idx` and fixes up the active +/// tab / route. +fn close_tab( + idx: usize, + mut tabs: Signal>, + mut active_tab: Signal, + navigator: Navigator, +) { + let vec_idx = idx - 1; + // Guard: idx is captured at event time; a rapid second close (or a close + // confirmed after the list changed) must not index out of bounds. + if vec_idx >= tabs.read().len() { + return; + } + let current_active = *active_tab.read(); + + tabs.write().remove(vec_idx); + let new_len = tabs.read().len(); + + if new_len == 0 { + *active_tab.write() = 0; + navigator.push(Route::Home {}); + } else if idx == current_active { + let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; + *active_tab.write() = new_active; + if let Some(tab) = tabs.read().get(new_active - 1) { + navigator.push(Route::Editor { + path: tab.path.clone(), + }); + } + } else if idx < current_active { + *active_tab.write() = current_active - 1; + } +} + /// Persistent application shell. #[component] pub fn Shell() -> Element { - let mut tabs = use_context::>>(); + let tabs = use_context::>>(); let mut active_tab = use_context::>(); let navigator = use_navigator(); + // A dirty tab awaiting close confirmation: `(tab-bar index, title)`. + // While `Some`, the confirmation dialog overlays the shell (plan 4b.6). + let mut pending_close: Signal> = use_signal(|| None); rsx! { div { + // position: relative anchors the AtConfirmDialog overlay (its + // absolute backdrop resolves against this shell-sized box). style: format!( - "height: 100vh; display: flex; flex-direction: column; \ + "height: 100vh; position: relative; \ + display: flex; flex-direction: column; \ overflow: hidden; background: {bg};", bg = tokens::COLOR_SURFACE_BASE, ), @@ -49,29 +91,18 @@ pub fn Shell() -> Element { return; // Home tab cannot be closed. } let vec_idx = idx - 1; - // Guard: idx is captured at render time; a rapid second close event - // before the deferred DOM re-render produces a stale vec_idx that is - // already out of bounds after the first removal. - if vec_idx >= tabs.read().len() { + // A dirty tab gets a confirmation dialog instead of an + // immediate close — this app has no session stash, so + // closing discards its unsaved edits outright (plan 4b.6). + let dirty_title = tabs + .read() + .get(vec_idx) + .and_then(|t| t.is_dirty.then(|| t.title.clone())); + if let Some(title) = dirty_title { + pending_close.set(Some((idx, title))); return; } - let current_active = *active_tab.read(); - - tabs.write().remove(vec_idx); - let new_len = tabs.read().len(); - - if new_len == 0 { - *active_tab.write() = 0; - navigator.push(Route::Home {}); - } else if idx == current_active { - let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; - *active_tab.write() = new_active; - if let Some(tab) = tabs.read().get(new_active - 1) { - navigator.push(Route::Editor { path: tab.path.clone() }); - } - } else if idx < current_active { - *active_tab.write() = current_active - 1; - } + close_tab(idx, tabs, active_tab, navigator); }, on_new_tab: move |_| { *active_tab.write() = 0; @@ -89,6 +120,21 @@ pub fn Shell() -> Element { ), Outlet:: {} } + + // ── Dirty-tab close confirmation (ADR-0013 boundary mount) ──────── + {pending_close.read().clone().map(|(idx, title)| rsx! { + AtConfirmDialog { + title: fl!("shell-close-dirty-title"), + message: fl!("shell-close-dirty-message", title = title), + confirm_label: fl!("shell-close-dirty-confirm"), + cancel_label: fl!("shell-close-dirty-cancel"), + on_confirm: move |_| { + pending_close.set(None); + close_tab(idx, tabs, active_tab, navigator); + }, + on_cancel: move |_| pending_close.set(None), + } + })} } } } diff --git a/loki-spreadsheet/src/routes/home.rs b/loki-spreadsheet/src/routes/home.rs index 48042a34..9544e915 100644 --- a/loki-spreadsheet/src/routes/home.rs +++ b/loki-spreadsheet/src/routes/home.rs @@ -2,11 +2,13 @@ //! Home screen route component for loki-spreadsheet. -use appthere_ui::{AtHomeTab, BuiltinTemplate, RecentDocument}; +use appthere_ui::{AtConfirmDialog, AtHomeTab, BuiltinTemplate, RecentDocument}; use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, PickOptions, PickerError, SaveOptions}; use loki_i18n::fl; +use super::home_util::{close_tab_for_path, push_or_switch_tab, suggested_copy_name}; + use crate::new_document::new_blank_tab; use crate::recent_documents::RecentDocuments; use crate::routes::Route; @@ -42,48 +44,6 @@ fn make_templates() -> Vec { ] } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Push `path` as a new open tab, or switch to its existing tab if already open. -fn push_or_switch_tab(mut tabs: Signal>, mut active_tab: Signal, path: String) { - let title = display_title_from_path(&path); - let existing = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = existing { - *active_tab.write() = idx + 1; - } else { - tabs.write().push(OpenTab { - title, - path, - is_dirty: false, - is_discarded: false, - }); - *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home - } -} - -/// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. -fn close_tab_for_path(mut tabs: Signal>, mut active_tab: Signal, path: &str) { - let removed = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = removed { - tabs.write().remove(idx); - // active_tab is 1-based (index 0 = Home). Reset to Home if the active - // selection pointed at or past the removed tab to avoid a stale index. - if *active_tab.read() > idx { - *active_tab.write() = 0; - } - } -} - -/// Build a " Copy." filename from a token's display name. -fn suggested_copy_name(token: &FileAccessToken) -> String { - let name = token.display_name(); - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), - _ => format!("{name} Copy"), - } -} - // ── Home ────────────────────────────────────────────────────────────────────── /// Home screen component. @@ -171,15 +131,25 @@ pub fn Home() -> Element { // ── on_recent_delete ────────────────────────────────────────────────────── // - // `path` is a serialised FileAccessToken, not a filesystem path. Decode it, - // delete the underlying file via the capability token, close any open tab - // for it, then drop the recents entry. + // Deleting a file is destructive, so the menu action only *requests* it: + // the confirmation dialog below performs the deletion on confirm (4c.1). + let mut pending_delete: Signal> = use_signal(|| None); let on_recent_delete = move |idx: usize| { - let mut err_sig = pick_error; - let Some(path) = recent_docs.read().entries.get(idx).map(|e| e.path.clone()) else { - return; - }; + let entry = recent_docs + .read() + .entries + .get(idx) + .map(|e| (e.path.clone(), e.title.clone())); + if let Some(path_and_title) = entry { + pending_delete.set(Some(path_and_title)); + } + }; + // The confirmed deletion. `path` is a serialised FileAccessToken, not a + // filesystem path: decode it, delete the underlying file via the + // capability token, close any open tab for it, then drop the recents entry. + let mut delete_recent = move |path: String| { + let mut err_sig = pick_error; match FileAccessToken::deserialize(&path) { Ok(token) => { if let Err(e) = token.delete() { @@ -192,7 +162,6 @@ pub fn Home() -> Element { } close_tab_for_path(tabs, active_tab, &path); - // TODO(ux): Add a confirmation dialog before destructive deletion. recent_docs.write().remove(&path); recent_docs.read().save(); }; @@ -266,26 +235,46 @@ pub fn Home() -> Element { .collect(); rsx! { - AtHomeTab { - templates: make_templates(), - recent_documents: recent_list, - templates_label: fl!("home-templates-heading"), - recent_label: fl!("home-recent-heading"), - browse_label: String::new(), - open_file_label: fl!("home-open-file"), - empty_recent_label: fl!("home-no-recent"), - recent_menu_aria_label: fl!("home-recent-menu-aria"), - recent_remove_label: fl!("home-recent-menu-remove"), - recent_delete_label: fl!("home-recent-menu-delete"), - recent_open_copy_label: fl!("home-recent-menu-open-copy"), - pick_error: pick_error, - on_template_select: on_template_select, - on_browse_templates: |_| {}, - on_recent_open: on_recent_open, - on_open_file: on_open_file, - on_recent_remove: on_recent_remove, - on_recent_delete: on_recent_delete, - on_recent_open_copy: on_recent_open_copy, + // position: relative anchors the AtConfirmDialog overlay over the + // home area (AtHomeTab sizes itself to the viewport minus tab bar). + div { + style: "position: relative;", + AtHomeTab { + templates: make_templates(), + recent_documents: recent_list, + templates_label: fl!("home-templates-heading"), + recent_label: fl!("home-recent-heading"), + browse_label: String::new(), + open_file_label: fl!("home-open-file"), + empty_recent_label: fl!("home-no-recent"), + recent_menu_aria_label: fl!("home-recent-menu-aria"), + recent_remove_label: fl!("home-recent-menu-remove"), + recent_delete_label: fl!("home-recent-menu-delete"), + recent_open_copy_label: fl!("home-recent-menu-open-copy"), + pick_error: pick_error, + on_template_select: on_template_select, + on_browse_templates: |_| {}, + on_recent_open: on_recent_open, + on_open_file: on_open_file, + on_recent_remove: on_recent_remove, + on_recent_delete: on_recent_delete, + on_recent_open_copy: on_recent_open_copy, + } + + // ── Delete confirmation (ADR-0013 boundary mount) ───────────────── + {pending_delete.read().clone().map(|(path, title)| rsx! { + AtConfirmDialog { + title: fl!("home-delete-confirm-title"), + message: fl!("home-delete-confirm-message", title = title), + confirm_label: fl!("home-delete-confirm-confirm"), + cancel_label: fl!("home-delete-confirm-cancel"), + on_confirm: move |_| { + pending_delete.set(None); + delete_recent(path.clone()); + }, + on_cancel: move |_| pending_delete.set(None), + } + })} } } } diff --git a/loki-spreadsheet/src/routes/home_util.rs b/loki-spreadsheet/src/routes/home_util.rs new file mode 100644 index 00000000..24c00073 --- /dev/null +++ b/loki-spreadsheet/src/routes/home_util.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Tab and file-token helpers for the Home route. +//! +//! Extracted from `home.rs` to keep that file under the 300-line ceiling. + +use dioxus::prelude::*; +use loki_file_access::FileAccessToken; + +use crate::tabs::OpenTab; +use crate::utils::display_title_from_path; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Push `path` as a new open tab, or switch to its existing tab if already open. +pub(super) fn push_or_switch_tab( + mut tabs: Signal>, + mut active_tab: Signal, + path: String, +) { + let title = display_title_from_path(&path); + let existing = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = existing { + *active_tab.write() = idx + 1; + } else { + tabs.write().push(OpenTab { + title, + path, + is_dirty: false, + is_discarded: false, + }); + *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home + } +} + +/// Close any open tab whose `path` matches `path`, resetting the active tab to +/// Home when the closed (or a now-shifted) tab was selected. +pub(super) fn close_tab_for_path( + mut tabs: Signal>, + mut active_tab: Signal, + path: &str, +) { + let removed = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = removed { + tabs.write().remove(idx); + // active_tab is 1-based (index 0 = Home). Reset to Home if the active + // selection pointed at or past the removed tab to avoid a stale index. + if *active_tab.read() > idx { + *active_tab.write() = 0; + } + } +} + +/// Build a " Copy." filename from a token's display name. +pub(super) fn suggested_copy_name(token: &FileAccessToken) -> String { + let name = token.display_name(); + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), + _ => format!("{name} Copy"), + } +} diff --git a/loki-spreadsheet/src/routes/mod.rs b/loki-spreadsheet/src/routes/mod.rs index c1fce9fa..2dd09491 100644 --- a/loki-spreadsheet/src/routes/mod.rs +++ b/loki-spreadsheet/src/routes/mod.rs @@ -5,6 +5,7 @@ pub mod editor; pub mod home; +mod home_util; pub mod shell; use dioxus::prelude::*; diff --git a/loki-spreadsheet/src/routes/shell.rs b/loki-spreadsheet/src/routes/shell.rs index 4b0b9e12..48b8d27d 100644 --- a/loki-spreadsheet/src/routes/shell.rs +++ b/loki-spreadsheet/src/routes/shell.rs @@ -3,24 +3,66 @@ //! Persistent application shell wrapping all routes for loki-spreadsheet. use appthere_ui::tokens; -use appthere_ui::{AtDocumentTabData, AtTabBar}; +use appthere_ui::{AtConfirmDialog, AtDocumentTabData, AtTabBar}; use dioxus::prelude::*; +use dioxus_router::Navigator; use loki_i18n::fl; use crate::routes::Route; use crate::tabs::OpenTab; +/// Closes the tab at 1-based tab-bar index `idx` and fixes up the active +/// tab / route. +fn close_tab( + idx: usize, + mut tabs: Signal>, + mut active_tab: Signal, + navigator: Navigator, +) { + let vec_idx = idx - 1; + // Guard: idx is captured at event time; a rapid second close (or a close + // confirmed after the list changed) must not index out of bounds. + if vec_idx >= tabs.read().len() { + return; + } + let current_active = *active_tab.read(); + + tabs.write().remove(vec_idx); + let new_len = tabs.read().len(); + + if new_len == 0 { + *active_tab.write() = 0; + navigator.push(Route::Home {}); + } else if idx == current_active { + let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; + *active_tab.write() = new_active; + if let Some(tab) = tabs.read().get(new_active - 1) { + navigator.push(Route::Editor { + path: tab.path.clone(), + }); + } + } else if idx < current_active { + *active_tab.write() = current_active - 1; + } +} + /// Persistent application shell. #[component] pub fn Shell() -> Element { - let mut tabs = use_context::>>(); + let tabs = use_context::>>(); let mut active_tab = use_context::>(); let navigator = use_navigator(); + // A dirty tab awaiting close confirmation: `(tab-bar index, title)`. + // While `Some`, the confirmation dialog overlays the shell (plan 4b.6). + let mut pending_close: Signal> = use_signal(|| None); rsx! { div { + // position: relative anchors the AtConfirmDialog overlay (its + // absolute backdrop resolves against this shell-sized box). style: format!( - "height: 100vh; display: flex; flex-direction: column; \ + "height: 100vh; position: relative; \ + display: flex; flex-direction: column; \ overflow: hidden; background: {bg};", bg = tokens::COLOR_SURFACE_BASE, ), @@ -49,29 +91,18 @@ pub fn Shell() -> Element { return; // Home tab cannot be closed. } let vec_idx = idx - 1; - // Guard: idx is captured at render time; a rapid second close event - // before the deferred DOM re-render produces a stale vec_idx that is - // already out of bounds after the first removal. - if vec_idx >= tabs.read().len() { + // A dirty tab gets a confirmation dialog instead of an + // immediate close — this app has no session stash, so + // closing discards its unsaved edits outright (plan 4b.6). + let dirty_title = tabs + .read() + .get(vec_idx) + .and_then(|t| t.is_dirty.then(|| t.title.clone())); + if let Some(title) = dirty_title { + pending_close.set(Some((idx, title))); return; } - let current_active = *active_tab.read(); - - tabs.write().remove(vec_idx); - let new_len = tabs.read().len(); - - if new_len == 0 { - *active_tab.write() = 0; - navigator.push(Route::Home {}); - } else if idx == current_active { - let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; - *active_tab.write() = new_active; - if let Some(tab) = tabs.read().get(new_active - 1) { - navigator.push(Route::Editor { path: tab.path.clone() }); - } - } else if idx < current_active { - *active_tab.write() = current_active - 1; - } + close_tab(idx, tabs, active_tab, navigator); }, on_new_tab: move |_| { *active_tab.write() = 0; @@ -89,6 +120,21 @@ pub fn Shell() -> Element { ), Outlet:: {} } + + // ── Dirty-tab close confirmation (ADR-0013 boundary mount) ──────── + {pending_close.read().clone().map(|(idx, title)| rsx! { + AtConfirmDialog { + title: fl!("shell-close-dirty-title"), + message: fl!("shell-close-dirty-message", title = title), + confirm_label: fl!("shell-close-dirty-confirm"), + cancel_label: fl!("shell-close-dirty-cancel"), + on_confirm: move |_| { + pending_close.set(None); + close_tab(idx, tabs, active_tab, navigator); + }, + on_cancel: move |_| pending_close.set(None), + } + })} } } } diff --git a/loki-text/src/routes/home.rs b/loki-text/src/routes/home.rs index 06408dc2..61659dd3 100644 --- a/loki-text/src/routes/home.rs +++ b/loki-text/src/routes/home.rs @@ -9,11 +9,14 @@ //! All user-visible strings come from `loki_i18n::fl!()` — no hardcoded //! English literals. -use appthere_ui::{AtHomeTab, BuiltinTemplate, RecentDocument}; +use appthere_ui::{AtConfirmDialog, AtHomeTab, BuiltinTemplate, RecentDocument}; use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, PickOptions, PickerError, SaveOptions}; use loki_i18n::fl; +use super::home_util::{ + close_tab_for_path, is_template_name, push_new_tab, push_or_switch_tab, suggested_copy_name, +}; use crate::new_document::{new_blank_tab, new_import_tab, new_template_tab}; use crate::recent_documents::RecentDocuments; use crate::routes::Route; @@ -71,71 +74,6 @@ fn make_templates() -> Vec { ] } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Push `path` as a new open tab, or switch to its existing tab if already open. -fn push_or_switch_tab(mut tabs: Signal>, mut active_tab: Signal, path: String) { - let title = display_title_from_path(&path); - let existing = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = existing { - *active_tab.write() = idx + 1; - } else { - tabs.write().push(OpenTab { - title, - path, - is_dirty: false, - is_discarded: false, - }); - // TODO(tabs): Replace router-driven navigation with tab-driven navigation. - *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home - } -} - -/// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. -fn close_tab_for_path(mut tabs: Signal>, mut active_tab: Signal, path: &str) { - let removed = tabs.read().iter().position(|t| t.path == path); - if let Some(idx) = removed { - tabs.write().remove(idx); - // active_tab is 1-based (index 0 = Home). Reset to Home if the active - // selection pointed at or past the removed tab to avoid a stale index. - if *active_tab.read() > idx { - *active_tab.write() = 0; - } - } -} - -/// True if `name` has a template extension (Word `.dotx`/`.dotm` or -/// LibreOffice `.ott`/`.ots`). Templates open as fresh, detached documents. -fn is_template_name(name: &str) -> bool { - name.rsplit('.') - .next() - .map(|e| e.to_ascii_lowercase()) - .is_some_and(|e| matches!(e.as_str(), "dotx" | "dotm" | "ott" | "ots")) -} - -/// Push `tab` as a new open tab (last position) and return its path so the -/// caller can navigate to the editor. -fn push_new_tab( - mut tabs: Signal>, - mut active_tab: Signal, - tab: OpenTab, -) -> String { - let path = tab.path.clone(); - tabs.write().push(tab); - *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home - path -} - -/// Build a " Copy." filename from a token's display name. -fn suggested_copy_name(token: &FileAccessToken) -> String { - let name = token.display_name(); - match name.rsplit_once('.') { - Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), - _ => format!("{name} Copy"), - } -} - // ── Home ────────────────────────────────────────────────────────────────────── /// Home screen component — wraps [`AtHomeTab`] with Loki Text data and routing. @@ -240,15 +178,25 @@ pub fn Home() -> Element { // ── on_recent_delete ────────────────────────────────────────────────────── // - // `path` is a serialised FileAccessToken, not a filesystem path. Decode it, - // delete the underlying file via the capability token, close any open tab - // for it, then drop the recents entry. + // Deleting a file is destructive, so the menu action only *requests* it: + // the confirmation dialog below performs the deletion on confirm (4c.1). + let mut pending_delete: Signal> = use_signal(|| None); let on_recent_delete = move |idx: usize| { - let mut err_sig = pick_error; - let Some(path) = recent_docs.read().entries.get(idx).map(|e| e.path.clone()) else { - return; - }; + let entry = recent_docs + .read() + .entries + .get(idx) + .map(|e| (e.path.clone(), e.title.clone())); + if let Some(path_and_title) = entry { + pending_delete.set(Some(path_and_title)); + } + }; + // The confirmed deletion. `path` is a serialised FileAccessToken, not a + // filesystem path: decode it, delete the underlying file via the + // capability token, close any open tab for it, then drop the recents entry. + let mut delete_recent = move |path: String| { + let mut err_sig = pick_error; match FileAccessToken::deserialize(&path) { Ok(token) => { if let Err(e) = token.delete() { @@ -263,7 +211,6 @@ pub fn Home() -> Element { } close_tab_for_path(tabs, active_tab, &path); - // TODO(ux): Add a confirmation dialog before destructive deletion. recent_docs.write().remove(&path); recent_docs.read().save(); }; @@ -337,8 +284,12 @@ pub fn Home() -> Element { .collect(); rsx! { - AtHomeTab { - templates: make_templates(), + // position: relative anchors the AtConfirmDialog overlay over the + // home area (AtHomeTab sizes itself to the viewport minus tab bar). + div { + style: "position: relative;", + AtHomeTab { + templates: make_templates(), recent_documents: recent_list, templates_label: fl!("home-templates-heading"), recent_label: fl!("home-recent-heading"), @@ -350,15 +301,31 @@ pub fn Home() -> Element { recent_remove_label: fl!("home-recent-menu-remove"), recent_delete_label: fl!("home-recent-menu-delete"), recent_open_copy_label: fl!("home-recent-menu-open-copy"), - pick_error: pick_error, - on_template_select: on_template_select, - // TODO(browse-templates): open a template browser dialog. - on_browse_templates: |_| {}, - on_recent_open: on_recent_open, - on_open_file: on_open_file, - on_recent_remove: on_recent_remove, - on_recent_delete: on_recent_delete, - on_recent_open_copy: on_recent_open_copy, + pick_error: pick_error, + on_template_select: on_template_select, + // TODO(browse-templates): open a template browser dialog. + on_browse_templates: |_| {}, + on_recent_open: on_recent_open, + on_open_file: on_open_file, + on_recent_remove: on_recent_remove, + on_recent_delete: on_recent_delete, + on_recent_open_copy: on_recent_open_copy, + } + + // ── Delete confirmation (ADR-0013 boundary mount) ───────────────── + {pending_delete.read().clone().map(|(path, title)| rsx! { + AtConfirmDialog { + title: fl!("home-delete-confirm-title"), + message: fl!("home-delete-confirm-message", title = title), + confirm_label: fl!("home-delete-confirm-confirm"), + cancel_label: fl!("home-delete-confirm-cancel"), + on_confirm: move |_| { + pending_delete.set(None); + delete_recent(path.clone()); + }, + on_cancel: move |_| pending_delete.set(None), + } + })} } } } diff --git a/loki-text/src/routes/home_util.rs b/loki-text/src/routes/home_util.rs new file mode 100644 index 00000000..9b050c8a --- /dev/null +++ b/loki-text/src/routes/home_util.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Tab and file-token helpers for the Home route. +//! +//! Extracted from `home.rs` to keep that file under the 300-line ceiling. + +use dioxus::prelude::*; +use loki_file_access::FileAccessToken; + +use crate::tabs::OpenTab; +use crate::utils::display_title_from_path; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Push `path` as a new open tab, or switch to its existing tab if already open. +pub(super) fn push_or_switch_tab( + mut tabs: Signal>, + mut active_tab: Signal, + path: String, +) { + let title = display_title_from_path(&path); + let existing = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = existing { + *active_tab.write() = idx + 1; + } else { + tabs.write().push(OpenTab { + title, + path, + is_dirty: false, + is_discarded: false, + }); + // TODO(tabs): Replace router-driven navigation with tab-driven navigation. + *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home + } +} + +/// Close any open tab whose `path` matches `path`, resetting the active tab to +/// Home when the closed (or a now-shifted) tab was selected. +pub(super) fn close_tab_for_path( + mut tabs: Signal>, + mut active_tab: Signal, + path: &str, +) { + let removed = tabs.read().iter().position(|t| t.path == path); + if let Some(idx) = removed { + tabs.write().remove(idx); + // active_tab is 1-based (index 0 = Home). Reset to Home if the active + // selection pointed at or past the removed tab to avoid a stale index. + if *active_tab.read() > idx { + *active_tab.write() = 0; + } + } +} + +/// True if `name` has a template extension (Word `.dotx`/`.dotm` or +/// LibreOffice `.ott`/`.ots`). Templates open as fresh, detached documents. +pub(super) fn is_template_name(name: &str) -> bool { + name.rsplit('.') + .next() + .map(|e| e.to_ascii_lowercase()) + .is_some_and(|e| matches!(e.as_str(), "dotx" | "dotm" | "ott" | "ots")) +} + +/// Push `tab` as a new open tab (last position) and return its path so the +/// caller can navigate to the editor. +pub(super) fn push_new_tab( + mut tabs: Signal>, + mut active_tab: Signal, + tab: OpenTab, +) -> String { + let path = tab.path.clone(); + tabs.write().push(tab); + *active_tab.write() = tabs.read().len(); // new tab is last; +1 for Home + path +} + +/// Build a " Copy." filename from a token's display name. +pub(super) fn suggested_copy_name(token: &FileAccessToken) -> String { + let name = token.display_name(); + match name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() => format!("{stem} Copy.{ext}"), + _ => format!("{name} Copy"), + } +} diff --git a/loki-text/src/routes/mod.rs b/loki-text/src/routes/mod.rs index b4ed9cd1..08914bdc 100644 --- a/loki-text/src/routes/mod.rs +++ b/loki-text/src/routes/mod.rs @@ -13,6 +13,7 @@ pub mod editor; pub mod home; +mod home_util; pub mod shell; use dioxus::prelude::*; diff --git a/loki-text/src/routes/shell.rs b/loki-text/src/routes/shell.rs index f6455881..ea5e27ff 100644 --- a/loki-text/src/routes/shell.rs +++ b/loki-text/src/routes/shell.rs @@ -17,14 +17,65 @@ //! ``` use appthere_ui::tokens; -use appthere_ui::{AtDocumentTabData, AtTabBar, use_safe_area}; +use appthere_ui::{AtConfirmDialog, AtDocumentTabData, AtTabBar, use_safe_area}; use dioxus::prelude::*; +use dioxus_router::Navigator; use loki_i18n::fl; use crate::routes::Route; use crate::sessions::DocSessions; use crate::tabs::OpenTab; +/// Closes the tab at 1-based tab-bar index `idx`: drops its stashed editing +/// session (so a later reopen loads fresh from disk instead of resurrecting +/// discarded unsaved edits) and fixes up the active tab / route. +fn close_tab( + idx: usize, + mut tabs: Signal>, + mut active_tab: Signal, + mut doc_sessions: Signal, + navigator: Navigator, +) { + let vec_idx = idx - 1; + // Guard: idx is captured at event time; a rapid second close (or a close + // confirmed after the list changed) must not index out of bounds. + if vec_idx >= tabs.read().len() { + return; + } + let current_active = *active_tab.read(); + + let closed_path = tabs.read().get(vec_idx).map(|t| t.path.clone()); + if let Some(p) = closed_path { + doc_sessions.write().remove(&p); + } + + tabs.write().remove(vec_idx); + let new_len = tabs.read().len(); + + if new_len == 0 { + // No documents remain — go Home. + *active_tab.write() = 0; + navigator.push(Route::Home {}); + } else if idx == current_active { + // Closed the active tab — activate the nearest remaining tab. + // Prefer the tab to the left; fall back to the first tab. + let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; + *active_tab.write() = new_active; + if let Some(tab) = tabs.read().get(new_active - 1) { + navigator.push(Route::Editor { + path: tab.path.clone(), + }); + } + } else if idx < current_active { + // Closed a tab to the LEFT of the active tab — the Vec shifted so the + // active document's index decrements by 1. Do NOT navigate: the + // displayed document is unchanged. + *active_tab.write() = current_active - 1; + } + // Closing a tab to the RIGHT of the active tab: no index change and no + // navigation — displayed document is unchanged. +} + /// Persistent application shell. /// /// Reads `Signal>` and `Signal` from Dioxus context @@ -36,10 +87,13 @@ use crate::tabs::OpenTab; /// space below the tab bar. #[component] pub fn Shell() -> Element { - let mut tabs = use_context::>>(); + let tabs = use_context::>>(); let mut active_tab = use_context::>(); - let mut doc_sessions = use_context::>(); + let doc_sessions = use_context::>(); let navigator = use_navigator(); + // A dirty tab awaiting close confirmation: `(tab-bar index, title)`. + // While `Some`, the confirmation dialog overlays the shell (plan 4b.6). + let mut pending_close: Signal> = use_signal(|| None); // Safe-area insets are set by android_main from the OS-reported system-bar // heights before Dioxus launches. On desktop they are always (0, 0, 0, 0). @@ -72,8 +126,11 @@ pub fn Shell() -> Element { // definite length for child flex: 1 to resolve against. The safe-area // insets are pre-summed into a single value so the expression stays in // the `calc(100vh - Npx)` form that is confirmed working in Blitz. + // position: relative anchors the AtConfirmDialog overlay (its + // absolute backdrop resolves against this shell-sized box). style: format!( "height: {shell_height}; \ + position: relative; \ display: flex; flex-direction: column; \ overflow: hidden; background: {bg};", bg = tokens::COLOR_SURFACE_BASE, @@ -103,45 +160,18 @@ pub fn Shell() -> Element { return; // Home tab cannot be closed. } let vec_idx = idx - 1; - // Guard: idx is captured at render time; a rapid second close event - // before the deferred DOM re-render produces a stale vec_idx that is - // already out of bounds after the first removal. - if vec_idx >= tabs.read().len() { + // A dirty tab gets a confirmation dialog instead of an + // immediate close — closing discards its unsaved edits + // together with the stashed session (plan 4b.6 / F3c). + let dirty_title = tabs + .read() + .get(vec_idx) + .and_then(|t| t.is_dirty.then(|| t.title.clone())); + if let Some(title) = dirty_title { + pending_close.set(Some((idx, title))); return; } - let current_active = *active_tab.read(); - - // Drop the closed document's stashed editing session so a - // later reopen loads fresh from disk instead of resurrecting - // discarded unsaved edits. - let closed_path = tabs.read().get(vec_idx).map(|t| t.path.clone()); - if let Some(p) = closed_path { - doc_sessions.write().remove(&p); - } - - tabs.write().remove(vec_idx); - let new_len = tabs.read().len(); - - if new_len == 0 { - // No documents remain — go Home. - *active_tab.write() = 0; - navigator.push(Route::Home {}); - } else if idx == current_active { - // Closed the active tab — activate the nearest remaining tab. - // Prefer the tab to the left; fall back to the first tab. - let new_active = if vec_idx > 0 { idx - 1 } else { 1 }; - *active_tab.write() = new_active; - if let Some(tab) = tabs.read().get(new_active - 1) { - navigator.push(Route::Editor { path: tab.path.clone() }); - } - } else if idx < current_active { - // Closed a tab to the LEFT of the active tab — the Vec - // shifted so the active document's index decrements by 1. - // Do NOT navigate: the displayed document is unchanged. - *active_tab.write() = current_active - 1; - } - // Closing a tab to the RIGHT of the active tab: no index - // change and no navigation — displayed document is unchanged. + close_tab(idx, tabs, active_tab, doc_sessions, navigator); }, on_new_tab: move |_| { // Navigate to Home so the user can pick a template or file. @@ -167,6 +197,21 @@ pub fn Shell() -> Element { ), Outlet:: {} } + + // ── Dirty-tab close confirmation (ADR-0013 boundary mount) ──────── + {pending_close.read().clone().map(|(idx, title)| rsx! { + AtConfirmDialog { + title: fl!("shell-close-dirty-title"), + message: fl!("shell-close-dirty-message", title = title), + confirm_label: fl!("shell-close-dirty-confirm"), + cancel_label: fl!("shell-close-dirty-cancel"), + on_confirm: move |_| { + pending_close.set(None); + close_tab(idx, tabs, active_tab, doc_sessions, navigator); + }, + on_cancel: move |_| pending_close.set(None), + } + })} } } } From 8fec9070afa629eb377ac8fe7a9c7180eed1bc91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:26:38 +0000 Subject: [PATCH 18/27] Session retention on tab switch for presentation and spreadsheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b.6 tail (audit F1 residual): switching away from a presentation tab silently discarded all in-memory edits — the editor reset its document signal on every path change. The spreadsheet had the identical hole (its sync_path_and_reset dropped the live CRDT). Both apps now mirror loki-text's session machinery: - New sessions.rs (per app): an app-level Signal map, keyed by the serialised file token, holding the live editing state of inactive tabs. Presentation stashes doc + active slide index + dirty; spreadsheet stashes the Loro doc + undo manager + workbook snapshot + selection (its dirty flag already lives on the tab). - Stash/restore runs at three seams: synchronous path change (tab switch reuses the component), Editor -> Home unmount (use_drop), and remount (use_hook). Stashing is skipped when no tab points at the old path any more, so a closed or Save-As-repointed tab cannot resurrect stale state. - Both shells' close_tab now drops the closed tab's session, matching loki-text. - The presentation load resource now returns (path, result) and the populate effect guards on it — the stale-value clobber guard the other two apps already had, which matters more now that a restored session must not be overwritten by a lagging disk load. File-ceiling ratchet: spreadsheet editor_inner.rs shrank 1244 -> ~1110 (Loro cell/column/style mutations extracted to editor_mutate.rs; the path-sync handover lives in editor_path_sync.rs); presentation editor_inner.rs stays under 300 by extracting editor_path_sync.rs and the Save/Save As flows (editor_save_flows.rs). Note: like loki-text's session machinery, the stash/restore seams are Dioxus-component logic with no headless test harness in this repo — verified by compile + clippy + the existing suites; no new tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 2 +- loki-presentation/src/app.rs | 5 + loki-presentation/src/lib.rs | 1 + .../src/routes/editor/editor_inner.rs | 134 ++++------ .../src/routes/editor/editor_path_sync.rs | 59 +++++ .../src/routes/editor/editor_save_flows.rs | 112 +++++++++ loki-presentation/src/routes/editor/mod.rs | 2 + loki-presentation/src/routes/shell.rs | 21 +- loki-presentation/src/sessions.rs | 30 +++ loki-spreadsheet/src/app.rs | 5 + loki-spreadsheet/src/lib.rs | 1 + .../src/routes/editor/editor_inner.rs | 238 +++++------------- .../src/routes/editor/editor_mutate.rs | 143 +++++++++++ .../src/routes/editor/editor_path_sync.rs | 118 +++++++++ loki-spreadsheet/src/routes/editor/mod.rs | 2 + loki-spreadsheet/src/routes/shell.rs | 21 +- loki-spreadsheet/src/sessions.rs | 37 +++ 18 files changed, 661 insertions(+), 272 deletions(-) create mode 100644 loki-presentation/src/routes/editor/editor_path_sync.rs create mode 100644 loki-presentation/src/routes/editor/editor_save_flows.rs create mode 100644 loki-presentation/src/sessions.rs create mode 100644 loki-spreadsheet/src/routes/editor/editor_mutate.rs create mode 100644 loki-spreadsheet/src/routes/editor/editor_path_sync.rs create mode 100644 loki-spreadsheet/src/sessions.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index c42b3b6e..5a317053 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -192,7 +192,7 @@ per-sub-item verdicts against HEAD `20b05a6`: | # | Original claim | Verdict | Evidence / residual | |---|---|---|---| -| F1 | Presentation editor is a hardcoded demo; no load/save | **RESOLVED** (core) | Real PPTX import (`editor_load.rs:40-54` → `PptxImport`) and export with Save/Save As (`editor_save.rs`, `editor_inner.rs:87-149`); ODP is a typed `UnsupportedFormat` (deferred by MVP scope, not faked). **Residual:** in-memory edits still discarded on tab switch (`editor_inner.rs:50-57` resets `doc` on path change) — now *warned* via dirty indicator, no longer silent. | +| F1 | Presentation editor is a hardcoded demo; no load/save | **RESOLVED** (core) | Real PPTX import (`editor_load.rs:40-54` → `PptxImport`) and export with Save/Save As (`editor_save.rs`, `editor_inner.rs:87-149`); ODP is a typed `UnsupportedFormat` (deferred by MVP scope, not faked). **Residual resolved 2026-07-05** (plan 4b.6): tab switches now stash/restore the live presentation via `sessions.rs` + `editor_path_sync.rs` (and loki-spreadsheet got the same treatment). | | F2 | Recents Delete/Copy are silent no-ops (3 apps) | **RESOLVED** | `FileAccessToken::delete()` / `copy_bytes_to()` exist (`patches/loki-file-access/src/token.rs:116,132`) and all three `home.rs` handlers use them; failures surfaced via `pick_error` + `errors.ftl` keys. The `TODO(ux)` confirm-dialog landed 2026-07-05 (plan 4c.1, `AtConfirmDialog`). | | F3 | Edits lost on tab switch; dirty flag never set | **LARGELY RESOLVED** | Per-tab retention via `loki-text/src/sessions.rs` `DocSession` (stash/restore in `editor_path_sync.rs:42-154`); dirty tracks a generation baseline (`editor_inner.rs:465-476`), cleared on save. **Residual (F3c) resolved 2026-07-05** (plan 4b.6): closing a dirty tab raises an `AtConfirmDialog` confirmation in all three apps' shells. | | F4 | Untitled documents cannot be saved; no Ctrl+S | **RESOLVED** | Save As via `pick_file_to_save` (`editor_inner.rs:484-535`); Ctrl/Cmd+S bound (`editor_keydown.rs:60-67`) routing untitled → Save As. | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 9d06a75c..145733a5 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -121,7 +121,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Remaining tail:** Spec 01's typed `SaveError` residual; disabling the ribbon Save button when clean (needs the dirty flag threaded into the ribbon). | M | | 4b.4 | `nested-nav` | Sibling-path navigation inside cell/note bodies (`navigation.rs:138,174`). | S | | 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | -| 4b.6 | F3c + F1 residual (audit §9) | **Dirty-close protection ✅ Done 2026-07-05:** closing a dirty tab now raises a confirmation dialog in **all three apps** (new `appthere_ui::AtConfirmDialog` overlay primitive — absolute backdrop + centred card, backdrop-cancel, 44 px touch targets; shells gain `pending_close` state + an extracted `close_tab` helper). **Remaining (F1 residual):** presentation still discards in-memory edits on tab *switch* (`editor_inner.rs:50-57` — needs a `sessions.rs`-style stash like loki-text's). | S–M | +| 4b.6 | F3c + F1 residual (audit §9) | **Dirty-close protection ✅ Done 2026-07-05:** closing a dirty tab now raises a confirmation dialog in **all three apps** (new `appthere_ui::AtConfirmDialog` overlay primitive — absolute backdrop + centred card, backdrop-cancel, 44 px touch targets; shells gain `pending_close` state + an extracted `close_tab` helper). **Tab-switch retention ✅ Done 2026-07-05 (F1 residual):** loki-presentation *and* loki-spreadsheet now stash the live editing state (presentation: doc + slide index + dirty; spreadsheet: CRDT + undo manager + grid snapshot + selection) into an app-level session map on tab switch / Editor→Home unmount and restore it on return — mirroring loki-text's `sessions.rs`; closing a tab drops the session. The presentation load path also gained the `(path, result)` stale-value guard the other apps already had. | S–M | | 4b.7 | F6c + F6f (audit §9) | **Selection editing ✅ Done 2026-07-05:** typing replaces the active selection, Backspace/Delete remove it, incl. multi-block ranges — `loki_doc_model::delete_selection_at` (merge-then-delete composition, whole range pre-validated so cross-container / table-spanning selections are rejected untouched), editor wiring in `editor_keydown_text.rs` (replace-typing is one undo entry); tests: `loro_selection_delete_tests.rs` (10) + editor unit tests (7). **Remaining:** clipboard copy/cut/paste (partially gated on the unimplemented dioxus-native-dom clipboard converter), and moving save/load I/O off the UI thread (`editor_ribbon.rs:93`, `editor_load.rs:56-101`). | M | ### 4c. Shell/UX polish TODOs (§2) — batchable diff --git a/loki-presentation/src/app.rs b/loki-presentation/src/app.rs index 19e79ab1..edf77f27 100644 --- a/loki-presentation/src/app.rs +++ b/loki-presentation/src/app.rs @@ -84,9 +84,14 @@ pub fn App() -> Element { let recent_docs: Signal = use_signal(|| RecentDocuments::load(crate::recent_documents::RECENT_FILE)); + // Stashed sessions for inactive tabs — unsaved edits survive tab switches. + let doc_sessions: Signal = + use_signal(std::collections::HashMap::new); + provide_context(tabs); provide_context(active_tab); provide_context(recent_docs); + provide_context(doc_sessions); let insets = use_safe_area(); diff --git a/loki-presentation/src/lib.rs b/loki-presentation/src/lib.rs index 8f758742..28ce8696 100644 --- a/loki-presentation/src/lib.rs +++ b/loki-presentation/src/lib.rs @@ -13,6 +13,7 @@ pub mod error; pub mod new_document; pub mod recent_documents; pub mod routes; +pub mod sessions; pub mod tabs; pub mod utils; diff --git a/loki-presentation/src/routes/editor/editor_inner.rs b/loki-presentation/src/routes/editor/editor_inner.rs index ddac4f80..2befdc14 100644 --- a/loki-presentation/src/routes/editor/editor_inner.rs +++ b/loki-presentation/src/routes/editor/editor_inner.rs @@ -13,7 +13,6 @@ use appthere_ui::AtStatusBar; use appthere_ui::tokens; use dioxus::prelude::*; -use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; use loki_i18n::fl; use loki_presentation_model::Presentation; @@ -21,22 +20,17 @@ use super::edit; use super::editor_canvas::{EditMsg, SlideCanvas, SlideThumbnails}; use super::editor_error_view::EditorErrorView; use super::editor_load::load_presentation; -use super::editor_save::export_to_token; +use super::editor_path_sync::{restore_session, stash_outgoing}; +use super::editor_save_flows::{SaveCtx, use_save_callbacks}; use super::slide_view::slide_views; -use crate::new_document::is_untitled; -use crate::recent_documents::RecentDocuments; -use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; -const PPTX_MIME: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; - /// Presentation editor inner component. #[component] pub(super) fn EditorInner(path: String) -> Element { - let navigator = use_navigator(); let mut tabs = use_context::>>(); - let recent_docs = use_context::>(); let mut path_signal = use_signal(|| path.clone()); let mut doc = use_signal(|| Option::::None); @@ -44,28 +38,59 @@ pub(super) fn EditorInner(path: String) -> Element { let mut active_idx = use_signal(|| 0usize); let mut dirty = use_signal(|| false); let mut save_message = use_signal(|| Option::::None); + // Stashed sessions for inactive tabs — unsaved edits survive tab switches + // (audit F1 residual / plan 4b.6). + let doc_sessions = use_context::>(); + + // ── Session restore at mount ────────────────────────────────────────────── + // Navigating Editor → Home unmounts this component (different routes), so + // returning to a presentation tab mounts a fresh EditorInner. The matching + // stash happens in the unmount hook below. + use_hook(move || { + let initial_path = path_signal.peek().clone(); + restore_session(&initial_path, doc_sessions, doc, active_idx, dirty); + }); - // Reset per-document state when the route path changes (tab switch / Save As - // navigation reuses this component instance). + // ── Session stash at unmount ────────────────────────────────────────────── + use_drop(move || { + let old_path = path_signal.peek().clone(); + stash_outgoing(&old_path, tabs, doc_sessions, doc, active_idx, dirty); + }); + + // Per-document state handover when the route path changes (tab switch / + // Save As navigation reuses this component instance): stash the outgoing + // presentation, then restore the incoming one or reset for a fresh load. if *path_signal.peek() != path { + let old_path = path_signal.peek().clone(); + stash_outgoing(&old_path, tabs, doc_sessions, doc, active_idx, dirty); path_signal.set(path.clone()); - doc.set(None); load_error.set(None); - active_idx.set(0); - dirty.set(false); save_message.set(None); + if !restore_session(&path, doc_sessions, doc, active_idx, dirty) { + doc.set(None); + active_idx.set(0); + dirty.set(false); + } } - // Load reactively on path; populate the editable doc once resolved. - let load = use_resource(move || async move { load_presentation(path_signal()) }); + // Load reactively on path; populate the editable doc once resolved. The + // result carries the path it was loaded for, so a stale value from the + // previous tab can never clobber a restored session or the wrong document + // (same guard as loki-text / loki-spreadsheet). + let load = use_resource(move || async move { + let p = path_signal(); + (p.clone(), load_presentation(p)) + }); use_effect(move || { if doc.peek().is_some() || load_error.peek().is_some() { return; } match &*load.value().read_unchecked() { - Some(Ok(p)) => doc.set(Some(p.clone())), - Some(Err(e)) => load_error.set(Some(e.to_string())), - None => {} + Some((loaded_path, Ok(p))) if *loaded_path == path_signal() => doc.set(Some(p.clone())), + Some((loaded_path, Err(e))) if *loaded_path == path_signal() => { + load_error.set(Some(e.to_string())); + } + _ => {} } }); @@ -83,69 +108,14 @@ pub(super) fn EditorInner(path: String) -> Element { let title = use_memo(move || display_title_from_path(&path_signal())); - // ── Save As ─────────────────────────────────────────────────────────────── - let save_as = use_callback(move |_: ()| { - let Some(pres) = doc.peek().clone() else { - return; - }; - let cur_path = path_signal.peek().clone(); - let suggested = format!("{}.pptx", display_title_from_path(&cur_path)); - let mut tabs = tabs; - let mut recent = recent_docs; - let nav = navigator; - spawn(async move { - let picker = FilePicker::new(); - let opts = SaveOptions { - mime_type: Some(PPTX_MIME.to_string()), - suggested_name: Some(suggested), - }; - match picker.pick_file_to_save(opts).await { - Ok(Some(token)) => match export_to_token(&token, &pres) { - Ok(()) => { - let new_path = token.serialize(); - let new_title = display_title_from_path(&new_path); - { - let mut t = tabs.write(); - if let Some(tab) = t.iter_mut().find(|tb| tb.path == cur_path) { - tab.path = new_path.clone(); - tab.title = new_title.clone(); - tab.is_dirty = false; - } - } - recent.write().record(new_path.clone(), new_title); - recent.read().save(); - dirty.set(false); - save_message.set(Some(fl!("editor-save-success"))); - nav.push(Route::Editor { path: new_path }); - } - Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e))), - }, - Ok(None) => {} - Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))), - } - }); - }); - - // ── Save ────────────────────────────────────────────────────────────────── - let save = use_callback(move |_: ()| { - let cur = path_signal.peek().clone(); - if is_untitled(&cur) { - save_as.call(()); - return; - } - let Some(pres) = doc.peek().clone() else { - return; - }; - match FileAccessToken::deserialize(&cur) { - Ok(token) => match export_to_token(&token, &pres) { - Ok(()) => { - dirty.set(false); - save_message.set(Some(fl!("editor-save-success"))); - } - Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e))), - }, - Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))), - } + // ── Save / Save As (extracted flows) ────────────────────────────────────── + // Only `save` is dispatched from the toolbar; it routes untitled + // documents to the Save As flow internally. + let (save, _save_as) = use_save_callbacks(SaveCtx { + doc, + path_signal, + dirty, + save_message, }); // ── Render states ────────────────────────────────────────────────────────── diff --git a/loki-presentation/src/routes/editor/editor_path_sync.rs b/loki-presentation/src/routes/editor/editor_path_sync.rs new file mode 100644 index 00000000..db54d266 --- /dev/null +++ b/loki-presentation/src/routes/editor/editor_path_sync.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Per-document session stash/restore for tab switches (audit F1 residual / +//! plan 4b.6) — see [`crate::sessions`] for the session map itself. +//! +//! Extracted from `editor_inner.rs` to keep that file under the 300-line +//! ceiling. + +use dioxus::prelude::*; +use loki_presentation_model::Presentation; + +use crate::sessions::{DocSession, DocSessions}; +use crate::tabs::OpenTab; + +/// Moves the live editable state into the session map. No-op when nothing is +/// loaded, or when no tab points at `old_path` any more — a closed (or +/// Save-As-repointed) tab must not resurrect its old state on reopen. +pub(super) fn stash_outgoing( + old_path: &str, + tabs: Signal>, + mut doc_sessions: Signal, + mut doc: Signal>, + active_idx: Signal, + dirty: Signal, +) { + let Some(pres) = doc.write().take() else { + return; + }; + if !tabs.peek().iter().any(|t| t.path == old_path) { + return; + } + doc_sessions.write().insert( + old_path.to_owned(), + DocSession { + doc: pres, + active_idx: *active_idx.peek(), + dirty: *dirty.peek(), + }, + ); +} + +/// Writes a stashed session back into the live editor state. Returns whether +/// a session existed (`false` → the caller resets for a fresh disk load). +pub(super) fn restore_session( + new_path: &str, + mut doc_sessions: Signal, + mut doc: Signal>, + mut active_idx: Signal, + mut dirty: Signal, +) -> bool { + let Some(session) = doc_sessions.write().remove(new_path) else { + return false; + }; + doc.set(Some(session.doc)); + active_idx.set(session.active_idx); + dirty.set(session.dirty); + true +} diff --git a/loki-presentation/src/routes/editor/editor_save_flows.rs b/loki-presentation/src/routes/editor/editor_save_flows.rs new file mode 100644 index 00000000..9d929500 --- /dev/null +++ b/loki-presentation/src/routes/editor/editor_save_flows.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Save and Save As flows for the presentation editor. +//! +//! Extracted from `editor_inner.rs` to keep that file under the 300-line +//! ceiling. Save As picks a destination, exports PPTX, repoints the tab, +//! records recents, and navigates; Save exports to the current token (or +//! routes untitled documents to Save As). + +use dioxus::prelude::*; +use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; +use loki_i18n::fl; +use loki_presentation_model::Presentation; + +use super::editor_save::export_to_token; +use crate::new_document::is_untitled; +use crate::recent_documents::RecentDocuments; +use crate::routes::Route; +use crate::tabs::OpenTab; +use crate::utils::display_title_from_path; + +const PPTX_MIME: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + +/// Signals the save flows read and write, grouped for the hook call. +pub(super) struct SaveCtx { + pub doc: Signal>, + pub path_signal: Signal, + pub dirty: Signal, + pub save_message: Signal>, +} + +/// Builds the `(save, save_as)` callbacks. Must be called unconditionally in +/// the component body (it registers hooks). +pub(super) fn use_save_callbacks(ctx: SaveCtx) -> (Callback<()>, Callback<()>) { + let SaveCtx { + doc, + path_signal, + mut dirty, + mut save_message, + } = ctx; + let navigator = use_navigator(); + let tabs = use_context::>>(); + let recent_docs = use_context::>(); + + // ── Save As ─────────────────────────────────────────────────────────────── + let save_as = use_callback(move |_: ()| { + let Some(pres) = doc.peek().clone() else { + return; + }; + let cur_path = path_signal.peek().clone(); + let suggested = format!("{}.pptx", display_title_from_path(&cur_path)); + let mut tabs = tabs; + let mut recent = recent_docs; + let nav = navigator; + spawn(async move { + let picker = FilePicker::new(); + let opts = SaveOptions { + mime_type: Some(PPTX_MIME.to_string()), + suggested_name: Some(suggested), + }; + match picker.pick_file_to_save(opts).await { + Ok(Some(token)) => match export_to_token(&token, &pres) { + Ok(()) => { + let new_path = token.serialize(); + let new_title = display_title_from_path(&new_path); + { + let mut t = tabs.write(); + if let Some(tab) = t.iter_mut().find(|tb| tb.path == cur_path) { + tab.path = new_path.clone(); + tab.title = new_title.clone(); + tab.is_dirty = false; + } + } + recent.write().record(new_path.clone(), new_title); + recent.read().save(); + dirty.set(false); + save_message.set(Some(fl!("editor-save-success"))); + nav.push(Route::Editor { path: new_path }); + } + Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e))), + }, + Ok(None) => {} + Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))), + } + }); + }); + + // ── Save ────────────────────────────────────────────────────────────────── + let save = use_callback(move |_: ()| { + let cur = path_signal.peek().clone(); + if is_untitled(&cur) { + save_as.call(()); + return; + } + let Some(pres) = doc.peek().clone() else { + return; + }; + match FileAccessToken::deserialize(&cur) { + Ok(token) => match export_to_token(&token, &pres) { + Ok(()) => { + dirty.set(false); + save_message.set(Some(fl!("editor-save-success"))); + } + Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e))), + }, + Err(e) => save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))), + } + }); + + (save, save_as) +} diff --git a/loki-presentation/src/routes/editor/mod.rs b/loki-presentation/src/routes/editor/mod.rs index cf46469d..32a5b47d 100644 --- a/loki-presentation/src/routes/editor/mod.rs +++ b/loki-presentation/src/routes/editor/mod.rs @@ -8,7 +8,9 @@ mod editor_canvas; mod editor_error_view; mod editor_inner; mod editor_load; +mod editor_path_sync; mod editor_save; +mod editor_save_flows; mod slide_view; use dioxus::prelude::*; diff --git a/loki-presentation/src/routes/shell.rs b/loki-presentation/src/routes/shell.rs index 3a6ed7ae..06949e34 100644 --- a/loki-presentation/src/routes/shell.rs +++ b/loki-presentation/src/routes/shell.rs @@ -9,14 +9,17 @@ use dioxus_router::Navigator; use loki_i18n::fl; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; -/// Closes the tab at 1-based tab-bar index `idx` and fixes up the active -/// tab / route. +/// Closes the tab at 1-based tab-bar index `idx`: drops its stashed editing +/// session (so a later reopen loads fresh from disk instead of resurrecting +/// discarded unsaved edits) and fixes up the active tab / route. fn close_tab( idx: usize, mut tabs: Signal>, mut active_tab: Signal, + mut doc_sessions: Signal, navigator: Navigator, ) { let vec_idx = idx - 1; @@ -27,6 +30,11 @@ fn close_tab( } let current_active = *active_tab.read(); + let closed_path = tabs.read().get(vec_idx).map(|t| t.path.clone()); + if let Some(p) = closed_path { + doc_sessions.write().remove(&p); + } + tabs.write().remove(vec_idx); let new_len = tabs.read().len(); @@ -51,6 +59,7 @@ fn close_tab( pub fn Shell() -> Element { let tabs = use_context::>>(); let mut active_tab = use_context::>(); + let doc_sessions = use_context::>(); let navigator = use_navigator(); // A dirty tab awaiting close confirmation: `(tab-bar index, title)`. // While `Some`, the confirmation dialog overlays the shell (plan 4b.6). @@ -92,8 +101,8 @@ pub fn Shell() -> Element { } let vec_idx = idx - 1; // A dirty tab gets a confirmation dialog instead of an - // immediate close — this app has no session stash, so - // closing discards its unsaved edits outright (plan 4b.6). + // immediate close — closing discards its unsaved edits + // together with the stashed session (plan 4b.6). let dirty_title = tabs .read() .get(vec_idx) @@ -102,7 +111,7 @@ pub fn Shell() -> Element { pending_close.set(Some((idx, title))); return; } - close_tab(idx, tabs, active_tab, navigator); + close_tab(idx, tabs, active_tab, doc_sessions, navigator); }, on_new_tab: move |_| { *active_tab.write() = 0; @@ -130,7 +139,7 @@ pub fn Shell() -> Element { cancel_label: fl!("shell-close-dirty-cancel"), on_confirm: move |_| { pending_close.set(None); - close_tab(idx, tabs, active_tab, navigator); + close_tab(idx, tabs, active_tab, doc_sessions, navigator); }, on_cancel: move |_| pending_close.set(None), } diff --git a/loki-presentation/src/sessions.rs b/loki-presentation/src/sessions.rs new file mode 100644 index 00000000..48b308b8 --- /dev/null +++ b/loki-presentation/src/sessions.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! In-memory editing sessions for inactive presentation tabs. +//! +//! When the user switches away from a presentation tab, `EditorInner` stashes +//! the live editable state here instead of discarding it; switching back +//! restores the session so unsaved edits survive tab switches (audit F1 +//! residual / plan 4b.6). Closing a tab drops its session (see +//! `routes/shell.rs`). +//! +//! The map is provided as `Signal` in Dioxus context at the +//! [`crate::app::App`] root. Sessions exist only for *inactive* tabs — the +//! active tab's state lives in the editor signals. + +use std::collections::HashMap; + +use loki_presentation_model::Presentation; + +/// Live editing state for one open-but-inactive presentation. +pub struct DocSession { + /// The editable presentation — holds all unsaved edits. + pub doc: Presentation, + /// Index of the slide that was active at stash time. + pub active_idx: usize, + /// Whether the presentation had unsaved edits at stash time. + pub dirty: bool, +} + +/// Open-but-inactive presentation sessions, keyed by the serialised file token. +pub type DocSessions = HashMap; diff --git a/loki-spreadsheet/src/app.rs b/loki-spreadsheet/src/app.rs index 573b5d8d..0c859a29 100644 --- a/loki-spreadsheet/src/app.rs +++ b/loki-spreadsheet/src/app.rs @@ -84,9 +84,14 @@ pub fn App() -> Element { let recent_docs: Signal = use_signal(|| RecentDocuments::load(crate::recent_documents::RECENT_FILE)); + // Stashed sessions for inactive tabs — unsaved edits survive tab switches. + let doc_sessions: Signal = + use_signal(std::collections::HashMap::new); + provide_context(tabs); provide_context(active_tab); provide_context(recent_docs); + provide_context(doc_sessions); let insets = use_safe_area(); diff --git a/loki-spreadsheet/src/lib.rs b/loki-spreadsheet/src/lib.rs index 0d22917b..4c9d14ba 100644 --- a/loki-spreadsheet/src/lib.rs +++ b/loki-spreadsheet/src/lib.rs @@ -18,6 +18,7 @@ pub mod error; pub mod new_document; pub mod recent_documents; pub mod routes; +pub mod sessions; pub mod tabs; pub mod utils; diff --git a/loki-spreadsheet/src/routes/editor/editor_inner.rs b/loki-spreadsheet/src/routes/editor/editor_inner.rs index 80ad667d..506c045c 100644 --- a/loki-spreadsheet/src/routes/editor/editor_inner.rs +++ b/loki-spreadsheet/src/routes/editor/editor_inner.rs @@ -15,58 +15,16 @@ use std::collections::HashSet; use super::cell_ref::{col_to_label, grid_dimensions}; use super::editor_load::{DocumentFormat, detect_format, load_document}; +use super::editor_mutate::{mutate_cell, mutate_cell_style, mutate_column_width}; +use super::editor_path_sync::{ + PathSyncSignals, restore_session, stash_outgoing, sync_path_and_reset, +}; use super::editor_state::{EditorState, use_editor_state}; use super::formula::{evaluate_cell, format_evaluated_value}; use crate::routes::Route; use crate::routes::dioxus_router::Navigator; use crate::utils::display_title_from_path; -/// Helper to mutate Loro cells in-place -fn mutate_cell( - ldoc: &loro::LoroDoc, - sheet_idx: usize, - row: u32, - col: u32, - val: String, - formula: Option, -) -> Result<(), loro::LoroError> { - let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); - let sheet_val = sheets_list - .get(sheet_idx) - .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; - let sheet_map = sheet_val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; - let cells_map = match sheet_map.get("cells") { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Cells container is not a map"))?, - None => sheet_map.insert_container("cells", loro::LoroMap::new())?, - }; - - let key = format!("{},{}", row, col); - let cell_map = match cells_map.get(&key) { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Cell container is not a map"))?, - None => cells_map.insert_container(&key, loro::LoroMap::new())?, - }; - - cell_map.insert("value", val.as_str())?; - if let Some(f) = formula { - cell_map.insert("formula", f.as_str())?; - } else { - let _ = cell_map.delete("formula"); - } - Ok(()) -} - /// Default rendered column width in CSS px (when the document specifies none). const DEFAULT_COL_PX: f64 = 100.0; /// Resize clamps (CSS px). @@ -90,94 +48,6 @@ struct ColResize { current_px: f64, } -/// Writes a column width (points) into the Loro sheet's `columns` map. -fn mutate_column_width( - ldoc: &loro::LoroDoc, - sheet_idx: usize, - col: u32, - width_pt: f64, -) -> Result<(), loro::LoroError> { - let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); - let sheet_val = sheets_list - .get(sheet_idx) - .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; - let sheet_map = sheet_val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; - let cols_map = match sheet_map.get("columns") { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Columns container is not a map"))?, - None => sheet_map.insert_container("columns", loro::LoroMap::new())?, - }; - cols_map.insert(col.to_string().as_str(), width_pt)?; - Ok(()) -} - -/// Helper to mutate cell style properties in-place -fn mutate_cell_style( - ldoc: &loro::LoroDoc, - sheet_idx: usize, - row: u32, - col: u32, - style_fn: F, -) -> Result<(), loro::LoroError> -where - F: FnOnce(&loro::LoroMap) -> Result<(), loro::LoroError>, -{ - let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); - let sheet_val = sheets_list - .get(sheet_idx) - .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; - let sheet_map = sheet_val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; - let cells_map = match sheet_map.get("cells") { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Cells container is not a map"))?, - None => sheet_map.insert_container("cells", loro::LoroMap::new())?, - }; - - let key = format!("{},{}", row, col); - let cell_map = match cells_map.get(&key) { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Cell container is not a map"))?, - None => cells_map.insert_container(&key, loro::LoroMap::new())?, - }; - - let style_map = match cell_map.get("style") { - Some(val) => val - .into_container() - .ok() - .and_then(|c| c.into_map().ok()) - .ok_or_else(|| loro::LoroError::internal("Style container is not a map"))?, - None => { - let m = cell_map.insert_container("style", loro::LoroMap::new())?; - m.insert("bold", false)?; - m.insert("italic", false)?; - m.insert("underline", false)?; - m.insert("align", "left")?; - m.insert("num_format", "general")?; - m - } - }; - - style_fn(&style_map)?; - Ok(()) -} - /// Dispatches changes to Loro, commits, deserializes back, and marks the active tab as dirty fn apply_change( loro_doc: Signal>, @@ -320,38 +190,6 @@ fn save_document( }); } -/// Reset per-document state when switching paths reactively -#[allow(clippy::too_many_arguments)] -fn sync_path_and_reset( - path: &str, - path_signal: &mut Signal, - workbook_snap: &mut Signal, - loro_doc: &mut Signal>, - undo_manager: &mut Signal>, - can_undo: &mut Signal, - can_redo: &mut Signal, - selected_cell: &mut Signal>, - editing_cell: &mut Signal>, -) { - let current = path_signal.peek().clone(); - if current == path { - return; - } - tracing::debug!( - "EditorInner: path changed from {} to {} -> resetting state", - current, - path - ); - path_signal.set(path.to_owned()); - workbook_snap.set(loki_sheet_model::Workbook::new()); - loro_doc.set(None); - undo_manager.set(None); - can_undo.set(false); - can_redo.set(false); - selected_cell.set(Some((0, 0))); - editing_cell.set(None); -} - /// Spreadsheet editor inner component. #[component] pub(super) fn EditorInner(path: String) -> Element { @@ -362,13 +200,15 @@ pub(super) fn EditorInner(path: String) -> Element { let tabs = use_context::>>(); let active_tab = use_context::>(); let active_tab_idx = *active_tab.read(); + // Stashed sessions for inactive tabs — unsaved edits survive tab switches. + let doc_sessions = use_context::>(); let EditorState { mut workbook_snap, mut loro_doc, mut undo_manager, - mut can_undo, - mut can_redo, + can_undo, + can_redo, mut selected_cell, mut editing_cell, } = use_editor_state(); @@ -376,17 +216,63 @@ pub(super) fn EditorInner(path: String) -> Element { // Transient save status (success or error) shown as a dismissible banner. let mut save_message = use_signal(|| Option::::None); - // ── Synchronous Path Sync & State Reset ────────────────────────────────── + // ── Session restore at mount ───────────────────────────────────────────── + // Navigating Editor → Home unmounts this component (different routes), so + // returning to a workbook tab mounts a fresh EditorInner. The matching + // stash happens in the unmount hook below. + { + let mut sessions_at_mount = doc_sessions; + use_hook(move || { + let initial_path = path_signal.peek().clone(); + if let Some(session) = sessions_at_mount.write().remove(&initial_path) { + let mut sig = PathSyncSignals { + workbook_snap, + loro_doc, + undo_manager, + can_undo, + can_redo, + selected_cell, + editing_cell, + }; + restore_session(session, &mut sig); + } + }); + } + + // ── Session stash at unmount ───────────────────────────────────────────── + { + let tabs_at_drop = tabs; + let sessions_at_drop = doc_sessions; + use_drop(move || { + let old_path = path_signal.peek().clone(); + let mut sig = PathSyncSignals { + workbook_snap, + loro_doc, + undo_manager, + can_undo, + can_redo, + selected_cell, + editing_cell, + }; + stash_outgoing(&old_path, tabs_at_drop, sessions_at_drop, &mut sig); + }); + } + + // ── Synchronous Path Sync & Session Handover ───────────────────────────── sync_path_and_reset( &path, &mut path_signal, - &mut workbook_snap, - &mut loro_doc, - &mut undo_manager, - &mut can_undo, - &mut can_redo, - &mut selected_cell, - &mut editing_cell, + tabs, + doc_sessions, + &mut PathSyncSignals { + workbook_snap, + loro_doc, + undo_manager, + can_undo, + can_redo, + selected_cell, + editing_cell, + }, ); // ── Document load — reactive on path_signal ─────────────────────────────── diff --git a/loki-spreadsheet/src/routes/editor/editor_mutate.rs b/loki-spreadsheet/src/routes/editor/editor_mutate.rs new file mode 100644 index 00000000..0cea04c4 --- /dev/null +++ b/loki-spreadsheet/src/routes/editor/editor_mutate.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! In-place Loro mutations for the spreadsheet CRDT: cell value/formula, +//! column width, and cell style writes. +//! +//! Extracted from `editor_inner.rs` (an oversized file) — see +//! [`super::editor_inner`] for the dispatch (`apply_change`) that commits and +//! re-snapshots after each of these mutations. + +/// Helper to mutate Loro cells in-place +pub(super) fn mutate_cell( + ldoc: &loro::LoroDoc, + sheet_idx: usize, + row: u32, + col: u32, + val: String, + formula: Option, +) -> Result<(), loro::LoroError> { + let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); + let sheet_val = sheets_list + .get(sheet_idx) + .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; + let sheet_map = sheet_val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; + let cells_map = match sheet_map.get("cells") { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Cells container is not a map"))?, + None => sheet_map.insert_container("cells", loro::LoroMap::new())?, + }; + + let key = format!("{},{}", row, col); + let cell_map = match cells_map.get(&key) { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Cell container is not a map"))?, + None => cells_map.insert_container(&key, loro::LoroMap::new())?, + }; + + cell_map.insert("value", val.as_str())?; + if let Some(f) = formula { + cell_map.insert("formula", f.as_str())?; + } else { + let _ = cell_map.delete("formula"); + } + Ok(()) +} + +/// Writes a column width (points) into the Loro sheet's `columns` map. +pub(super) fn mutate_column_width( + ldoc: &loro::LoroDoc, + sheet_idx: usize, + col: u32, + width_pt: f64, +) -> Result<(), loro::LoroError> { + let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); + let sheet_val = sheets_list + .get(sheet_idx) + .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; + let sheet_map = sheet_val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; + let cols_map = match sheet_map.get("columns") { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Columns container is not a map"))?, + None => sheet_map.insert_container("columns", loro::LoroMap::new())?, + }; + cols_map.insert(col.to_string().as_str(), width_pt)?; + Ok(()) +} + +/// Helper to mutate cell style properties in-place +pub(super) fn mutate_cell_style( + ldoc: &loro::LoroDoc, + sheet_idx: usize, + row: u32, + col: u32, + style_fn: F, +) -> Result<(), loro::LoroError> +where + F: FnOnce(&loro::LoroMap) -> Result<(), loro::LoroError>, +{ + let sheets_list = ldoc.get_list(loki_sheet_model::loro_bridge::KEY_SHEETS); + let sheet_val = sheets_list + .get(sheet_idx) + .ok_or_else(|| loro::LoroError::internal("Sheet not found"))?; + let sheet_map = sheet_val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Sheet is not a map"))?; + let cells_map = match sheet_map.get("cells") { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Cells container is not a map"))?, + None => sheet_map.insert_container("cells", loro::LoroMap::new())?, + }; + + let key = format!("{},{}", row, col); + let cell_map = match cells_map.get(&key) { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Cell container is not a map"))?, + None => cells_map.insert_container(&key, loro::LoroMap::new())?, + }; + + let style_map = match cell_map.get("style") { + Some(val) => val + .into_container() + .ok() + .and_then(|c| c.into_map().ok()) + .ok_or_else(|| loro::LoroError::internal("Style container is not a map"))?, + None => { + let m = cell_map.insert_container("style", loro::LoroMap::new())?; + m.insert("bold", false)?; + m.insert("italic", false)?; + m.insert("underline", false)?; + m.insert("align", "left")?; + m.insert("num_format", "general")?; + m + } + }; + + style_fn(&style_map)?; + Ok(()) +} diff --git a/loki-spreadsheet/src/routes/editor/editor_path_sync.rs b/loki-spreadsheet/src/routes/editor/editor_path_sync.rs new file mode 100644 index 00000000..f5f5e38d --- /dev/null +++ b/loki-spreadsheet/src/routes/editor/editor_path_sync.rs @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Path-change detection and per-document state handover for +//! [`super::editor_inner::EditorInner`]. +//! +//! On tab switch the outgoing workbook's live state (CRDT, undo history, grid +//! snapshot, selection) is stashed into the app-level +//! [`DocSessions`] map instead of being discarded, and the +//! incoming workbook's session is restored if one exists — unsaved edits +//! survive tab switches (plan 4b.6, mirrors `loki-text`). Only documents with +//! no stashed session fall through to the reset path (and are then loaded +//! from disk by `use_resource`). + +use dioxus::prelude::*; + +use crate::sessions::{DocSession, DocSessions}; +use crate::tabs::OpenTab; + +/// All per-document signals reset or restored on tab switch, grouped to keep +/// the [`sync_path_and_reset`] signature manageable. +pub(super) struct PathSyncSignals { + pub workbook_snap: Signal, + pub loro_doc: Signal>, + pub undo_manager: Signal>, + pub can_undo: Signal, + pub can_redo: Signal, + pub selected_cell: Signal>, + pub editing_cell: Signal>, +} + +/// Synchronises `path_signal` with the `path` prop. On change, stashes the +/// outgoing document's session, then either restores the incoming document's +/// stashed session or resets all per-document signals for a fresh disk load. +pub(super) fn sync_path_and_reset( + path: &str, + path_signal: &mut Signal, + tabs: Signal>, + mut sessions: Signal, + sig: &mut PathSyncSignals, +) { + let current = path_signal.peek().clone(); + if current == path { + return; + } + tracing::debug!( + "EditorInner: path changed from {} to {} → stashing outgoing session", + current, + path + ); + path_signal.set(path.to_owned()); + + stash_outgoing(¤t, tabs, sessions, sig); + + // Transient UI state never carries across documents. + sig.editing_cell.set(None); + + let restored = sessions.write().remove(path); + match restored { + Some(session) => restore_session(session, sig), + None => reset_for_fresh_load(sig), + } +} + +/// Move the outgoing document's live state into the session map. No-op when +/// no document ever finished loading, or when no tab points at `old_path` any +/// more — a closed (or Save-As-repointed) tab must not resurrect its old +/// state on reopen. +/// +/// Called on path change (doc → doc tab switch) and from the unmount hook in +/// `EditorInner` (doc → Home navigation unmounts the editor route). +pub(super) fn stash_outgoing( + old_path: &str, + tabs: Signal>, + mut sessions: Signal, + sig: &mut PathSyncSignals, +) { + let Some(loro_doc) = sig.loro_doc.write().take() else { + return; // nothing loaded — nothing to stash + }; + let undo_manager = sig.undo_manager.write().take(); + if !tabs.peek().iter().any(|t| t.path == old_path) { + return; + } + sessions.write().insert( + old_path.to_owned(), + DocSession { + loro_doc, + undo_manager, + workbook: sig.workbook_snap.peek().clone(), + can_undo: *sig.can_undo.peek(), + can_redo: *sig.can_redo.peek(), + selected_cell: *sig.selected_cell.peek(), + }, + ); +} + +/// Write a stashed session back into the live editor state. +/// +/// Called on path change and from the mount hook in `EditorInner` (returning +/// to a workbook tab after the editor route was unmounted by Home). +pub(super) fn restore_session(session: DocSession, sig: &mut PathSyncSignals) { + sig.workbook_snap.set(session.workbook); + sig.loro_doc.set(Some(session.loro_doc)); + sig.undo_manager.set(session.undo_manager); + sig.can_undo.set(session.can_undo); + sig.can_redo.set(session.can_redo); + sig.selected_cell.set(session.selected_cell); +} + +/// Reset all per-document state ahead of a fresh `load_document` pass. +fn reset_for_fresh_load(sig: &mut PathSyncSignals) { + sig.workbook_snap.set(loki_sheet_model::Workbook::new()); + sig.loro_doc.set(None); + sig.undo_manager.set(None); + sig.can_undo.set(false); + sig.can_redo.set(false); + sig.selected_cell.set(Some((0, 0))); +} diff --git a/loki-spreadsheet/src/routes/editor/mod.rs b/loki-spreadsheet/src/routes/editor/mod.rs index 11a0b419..7b59ac05 100644 --- a/loki-spreadsheet/src/routes/editor/mod.rs +++ b/loki-spreadsheet/src/routes/editor/mod.rs @@ -7,6 +7,8 @@ mod cell_ref; mod editor_error_view; mod editor_inner; mod editor_load; +mod editor_mutate; +mod editor_path_sync; mod editor_state; mod formula; #[cfg(test)] diff --git a/loki-spreadsheet/src/routes/shell.rs b/loki-spreadsheet/src/routes/shell.rs index 48b8d27d..a05c18cf 100644 --- a/loki-spreadsheet/src/routes/shell.rs +++ b/loki-spreadsheet/src/routes/shell.rs @@ -9,14 +9,17 @@ use dioxus_router::Navigator; use loki_i18n::fl; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; -/// Closes the tab at 1-based tab-bar index `idx` and fixes up the active -/// tab / route. +/// Closes the tab at 1-based tab-bar index `idx`: drops its stashed editing +/// session (so a later reopen loads fresh from disk instead of resurrecting +/// discarded unsaved edits) and fixes up the active tab / route. fn close_tab( idx: usize, mut tabs: Signal>, mut active_tab: Signal, + mut doc_sessions: Signal, navigator: Navigator, ) { let vec_idx = idx - 1; @@ -27,6 +30,11 @@ fn close_tab( } let current_active = *active_tab.read(); + let closed_path = tabs.read().get(vec_idx).map(|t| t.path.clone()); + if let Some(p) = closed_path { + doc_sessions.write().remove(&p); + } + tabs.write().remove(vec_idx); let new_len = tabs.read().len(); @@ -51,6 +59,7 @@ fn close_tab( pub fn Shell() -> Element { let tabs = use_context::>>(); let mut active_tab = use_context::>(); + let doc_sessions = use_context::>(); let navigator = use_navigator(); // A dirty tab awaiting close confirmation: `(tab-bar index, title)`. // While `Some`, the confirmation dialog overlays the shell (plan 4b.6). @@ -92,8 +101,8 @@ pub fn Shell() -> Element { } let vec_idx = idx - 1; // A dirty tab gets a confirmation dialog instead of an - // immediate close — this app has no session stash, so - // closing discards its unsaved edits outright (plan 4b.6). + // immediate close — closing discards its unsaved edits + // together with the stashed session (plan 4b.6). let dirty_title = tabs .read() .get(vec_idx) @@ -102,7 +111,7 @@ pub fn Shell() -> Element { pending_close.set(Some((idx, title))); return; } - close_tab(idx, tabs, active_tab, navigator); + close_tab(idx, tabs, active_tab, doc_sessions, navigator); }, on_new_tab: move |_| { *active_tab.write() = 0; @@ -130,7 +139,7 @@ pub fn Shell() -> Element { cancel_label: fl!("shell-close-dirty-cancel"), on_confirm: move |_| { pending_close.set(None); - close_tab(idx, tabs, active_tab, navigator); + close_tab(idx, tabs, active_tab, doc_sessions, navigator); }, on_cancel: move |_| pending_close.set(None), } diff --git a/loki-spreadsheet/src/sessions.rs b/loki-spreadsheet/src/sessions.rs new file mode 100644 index 00000000..e70339dc --- /dev/null +++ b/loki-spreadsheet/src/sessions.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! In-memory editing sessions for inactive workbook tabs. +//! +//! When the user switches away from a workbook tab, `EditorInner` stashes the +//! live CRDT and grid state here instead of discarding it; switching back +//! restores the session so unsaved edits survive tab switches (plan 4b.6, +//! mirrors `loki-text/src/sessions.rs`). Closing a tab drops its session (see +//! `routes/shell.rs`). +//! +//! The map is provided as `Signal` in Dioxus context at the +//! [`crate::app::App`] root. Sessions exist only for *inactive* tabs — the +//! active tab's state lives in the editor signals. The dirty flag needs no +//! stashing: it lives on the tab entry itself (`OpenTab::is_dirty`). + +use std::collections::HashMap; + +use loki_sheet_model::Workbook; + +/// Live editing state for one open-but-inactive workbook. +pub struct DocSession { + /// The live CRDT — holds all unsaved edits. + pub loro_doc: loro::LoroDoc, + /// Undo history paired with `loro_doc`. + pub undo_manager: Option, + /// Post-mutation workbook snapshot the grid renders from. + pub workbook: Workbook, + /// Ribbon/keyboard undo/redo state at stash time. + pub can_undo: bool, + /// Ribbon/keyboard undo/redo state at stash time. + pub can_redo: bool, + /// The selected cell at stash time (restored so the user resumes in place). + pub selected_cell: Option<(usize, usize)>, +} + +/// Open-but-inactive workbook sessions, keyed by the serialised file token. +pub type DocSessions = HashMap; From b5b85f41c508fe098a0a41930a818f91b71a5ef0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:32:42 +0000 Subject: [PATCH 19/27] Path-aware cursor navigation inside table cells and note bodies (4b.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b.4 (TODO(nested-nav)): paginated arrow navigation was blind to nested positions. Three defects, one root cause — everything addressed paragraphs by flat block index alone: - find_para_data matched only block_index, so every paragraph of a table resolved to the FIRST cell's layout entry (wrong cursor rects for Up/Down/Home/End inside any other cell). It now matches (block_index, path). - The get_text closures took a flat index, so grapheme moves inside a cell read the root table block's (empty) text. navigate_left/right/ end now take Fn(&BlockPath) and the keydown handler passes a get_block_text_at-backed getter (reflow navigation keeps the flat getter - tables carry no reflow editing data). - At a nested paragraph's edges, Left/Right constructed a top-level position, teleporting the cursor out of the cell. They now cross to the sibling block within the same container (verified to exist in the page layout) and clamp at the container's first/last block; crossing out of a cell is a separate future feature. The inline test module moved to navigation_tests.rs via the #[path] idiom (navigation.rs ratchets 593 -> 367 in the ceiling baseline), and 6 new regression tests cover sibling crossing, edge clamping (no teleporting to adjacent top-level paragraphs), nested grapheme moves, and End inside a cell. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 2 +- loki-text/src/editing/navigation.rs | 392 ++++-------------- loki-text/src/editing/navigation_tests.rs | 385 +++++++++++++++++ loki-text/src/routes/editor/editor_keydown.rs | 17 +- scripts/file-ceiling-baseline.txt | 8 +- 6 files changed, 487 insertions(+), 319 deletions(-) create mode 100644 loki-text/src/editing/navigation_tests.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 5a317053..d7d7b4ee 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -63,7 +63,7 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | `odt-fidelity` | `editor_load.rs:84,88` | Tracked DOCX/ODT import gaps | | `formatting` | `editor_formatting.rs:106` | Multi-block-selection formatting (clamped to focus paragraph) | | `undo-dirty` | `editor_state.rs:118` | ~~Saved-vs-undo-stack clean tracking~~ **fixed 2026-07-05** (plan 4b.3): `editing/saved_state.rs` clean checkpoint — undoing back to the save point clears dirty | -| `nested-nav` | `navigation.rs:138,174` | Sibling path inside cell/note body | +| `nested-nav` | `navigation.rs:138,174` | ~~Sibling path inside cell/note body~~ **fixed 2026-07-05** (plan 4b.4): paginated navigation is path-aware; Left/Right cross cell/note siblings and clamp at the container edge | | `tabs` | `shell.rs:148`, `home.rs:89` | Tab-driven (vs router) navigation; blank-doc | | `ux` | `text/home.rs:266` (+ presentation/spreadsheet) | Confirm-before-delete dialog (delete is immediate) | | `browse-templates` / `title-edit` | `text/home.rs:355`, `title_bar.rs:133` | Template browser dialog; inline-editable title | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 145733a5..3964cc52 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -119,7 +119,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4b.1 | `3b-3` | Finish cross-page navigation: left/right at page edges; `page_index` recompute after split/merge (currently `None`). | M | | 4b.2 | `formatting` | Multi-block-selection formatting (today clamped to the focus paragraph) — `editor_formatting.rs:106`. | M | | 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Remaining tail:** Spec 01's typed `SaveError` residual; disabling the ribbon Save button when clean (needs the dirty flag threaded into the ribbon). | M | -| 4b.4 | `nested-nav` | Sibling-path navigation inside cell/note bodies (`navigation.rs:138,174`). | S | +| 4b.4 | `nested-nav` | ✅ **Done 2026-07-05** — paginated navigation is path-aware end-to-end: `find_para_data` matches `(block_index, path)` (index alone returned the first cell's entry for every table paragraph), the `get_text` closures take a `BlockPath` (grapheme moves inside cells previously read the root block's empty text), and Left/Right at a nested paragraph's edges cross to the sibling within the same cell / note body, clamping at the container's first/last block. Inline tests extracted to `navigation_tests.rs` (`#[path]` idiom, file ratcheted 593 → 367) + 6 nested-nav regression tests. Reflow navigation stays top-level-only (tables have no reflow editing data). | S | | 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | | 4b.6 | F3c + F1 residual (audit §9) | **Dirty-close protection ✅ Done 2026-07-05:** closing a dirty tab now raises a confirmation dialog in **all three apps** (new `appthere_ui::AtConfirmDialog` overlay primitive — absolute backdrop + centred card, backdrop-cancel, 44 px touch targets; shells gain `pending_close` state + an extracted `close_tab` helper). **Tab-switch retention ✅ Done 2026-07-05 (F1 residual):** loki-presentation *and* loki-spreadsheet now stash the live editing state (presentation: doc + slide index + dirty; spreadsheet: CRDT + undo manager + grid snapshot + selection) into an app-level session map on tab switch / Editor→Home unmount and restore it on return — mirroring loki-text's `sessions.rs`; closing a tab drops the session. The presentation load path also gained the `(path, result)` stale-value guard the other apps already had. | S–M | | 4b.7 | F6c + F6f (audit §9) | **Selection editing ✅ Done 2026-07-05:** typing replaces the active selection, Backspace/Delete remove it, incl. multi-block ranges — `loki_doc_model::delete_selection_at` (merge-then-delete composition, whole range pre-validated so cross-container / table-spanning selections are rejected untouched), editor wiring in `editor_keydown_text.rs` (replace-typing is one undo entry); tests: `loro_selection_delete_tests.rs` (10) + editor unit tests (7). **Remaining:** clipboard copy/cut/paste (partially gated on the unimplemented dioxus-native-dom clipboard converter), and moving save/load I/O off the UI thread (`editor_ribbon.rs:93`, `editor_load.rs:56-101`). | M | diff --git a/loki-text/src/editing/navigation.rs b/loki-text/src/editing/navigation.rs index 04afa59b..1217d966 100644 --- a/loki-text/src/editing/navigation.rs +++ b/loki-text/src/editing/navigation.rs @@ -22,6 +22,7 @@ //! the list properties from the trailing empty block. //! `TODO(3b-3)`: implement `clear_para_props` / list-exit heuristic. +use loki_doc_model::{BlockPath, PathStep}; use loki_layout::{PageParagraphData, PaginatedLayout}; use super::cursor::{DocumentPosition, next_grapheme_boundary, prev_grapheme_boundary}; @@ -29,15 +30,20 @@ use super::hit_test::hit_test_page; // ── Internal helpers ────────────────────────────────────────────────────────── -/// Look up the [`PageParagraphData`] for a block on a specific page. +/// Look up the [`PageParagraphData`] for a paragraph on a specific page. /// /// `block_index` is the flat document block index stored in -/// [`DocumentPosition::paragraph_index`]. -fn find_para_data( - layout: &PaginatedLayout, +/// [`DocumentPosition::paragraph_index`]; `path` is the nested descent +/// ([`DocumentPosition::path`]). Nested paragraphs (table cells, note bodies) +/// share their root's `block_index`, so both must match — matching on the +/// index alone would return the first cell's entry for *every* paragraph of +/// a table. +fn find_para_data<'a>( + layout: &'a PaginatedLayout, page_index: usize, block_index: usize, -) -> Option<&PageParagraphData> { + path: &[PathStep], +) -> Option<&'a PageParagraphData> { layout .pages .get(page_index)? @@ -45,7 +51,38 @@ fn find_para_data( .as_ref()? .paragraphs .iter() - .find(|p| p.block_index == block_index) + .find(|p| p.block_index == block_index && p.path == path) +} + +/// The leaf block index of a nested position within its container (the leaf +/// step's block index; positions with an empty path are not nested). +fn nested_leaf(focus: &DocumentPosition) -> Option { + match focus.path.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => Some(*block), + None => None, + } +} + +/// The sibling of a nested `focus` shifted by `delta` blocks within its +/// container, if it exists in the page's layout. Clamps at the container's +/// first/last block by returning `None` — crossing out of a cell or note body +/// into the surrounding document is a separate feature. +fn nested_sibling( + focus: &DocumentPosition, + layout: &PaginatedLayout, + delta: isize, +) -> Option { + let leaf = nested_leaf(focus)?; + leaf.checked_add_signed(delta)?; // clamp at the container start + let sibling = focus.sibling_block(delta, 0); + // Only cross when the sibling paragraph actually exists in the layout. + find_para_data( + layout, + sibling.page_index, + sibling.paragraph_index, + &sibling.path, + )?; + Some(sibling) } /// Find the paragraph entry immediately preceding `block_index` in document order. @@ -102,23 +139,25 @@ fn find_next_para_data( /// /// - Within a paragraph: moves to the previous grapheme boundary. /// - At offset 0: moves to the end of the previous paragraph **on the same -/// page** if one exists. +/// page** — the previous sibling within the same container for a nested +/// position (table cell / note body), which clamps at the container's +/// first block. /// /// Returns `None` when already at the very start (first block on first page) /// or when no layout data is available. /// -/// `get_text(block_index)` is called to retrieve the plain text of a block. -/// Pass a closure over `loki_doc_model::get_block_text`. +/// `get_text(path)` is called to retrieve the plain text of the addressed +/// paragraph. Pass a closure over [`loki_doc_model::get_block_text_at`]. /// /// `TODO(3b-3)`: when at offset 0 of the first block on a page, navigate to /// the last character of the last block on the previous page. pub fn navigate_left( focus: &DocumentPosition, layout: &PaginatedLayout, - get_text: impl Fn(usize) -> String, + get_text: impl Fn(&BlockPath) -> String, ) -> Option { if focus.byte_offset > 0 { - let text = get_text(focus.paragraph_index); + let text = get_text(&focus.block_path()); let new_offset = prev_grapheme_boundary(&text, focus.byte_offset); return Some(DocumentPosition { byte_offset: new_offset, @@ -126,6 +165,17 @@ pub fn navigate_left( }); } + // At start of a nested paragraph — move to the end of the previous + // sibling within the same cell / note body (clamped at the first block). + if !focus.path.is_empty() { + let sibling = nested_sibling(focus, layout, -1)?; + let prev_text = get_text(&sibling.block_path()); + return Some(DocumentPosition { + byte_offset: prev_text.len(), + ..sibling + }); + } + // At start of paragraph — move to end of previous block on the same page. if focus.paragraph_index == 0 { // TODO(3b-3): navigate to last char of last block on previous page @@ -133,9 +183,8 @@ pub fn navigate_left( } let prev_index = focus.paragraph_index - 1; // Verify the previous block is actually on this page before crossing. - find_para_data(layout, focus.page_index, prev_index)?; - let prev_text = get_text(prev_index); - // TODO(nested-nav): a sibling inside a cell/note body must carry its path. + find_para_data(layout, focus.page_index, prev_index, &[])?; + let prev_text = get_text(&BlockPath::block(prev_index)); Some(DocumentPosition::top_level( focus.page_index, prev_index, @@ -147,7 +196,8 @@ pub fn navigate_left( /// /// - Within a paragraph: moves to the next grapheme boundary. /// - At the end: moves to the start of the next paragraph **on the same page** -/// if one exists. +/// — the next sibling within the same container for a nested position, +/// which clamps at the container's last block. /// /// Returns `None` when already at the very end or no layout data is available. /// @@ -155,9 +205,9 @@ pub fn navigate_left( pub fn navigate_right( focus: &DocumentPosition, layout: &PaginatedLayout, - get_text: impl Fn(usize) -> String, + get_text: impl Fn(&BlockPath) -> String, ) -> Option { - let text = get_text(focus.paragraph_index); + let text = get_text(&focus.block_path()); if focus.byte_offset < text.len() { let new_offset = next_grapheme_boundary(&text, focus.byte_offset); return Some(DocumentPosition { @@ -166,12 +216,17 @@ pub fn navigate_right( }); } + // At the end of a nested paragraph — move to the start of the next + // sibling within the same cell / note body (clamped at the last block). + if !focus.path.is_empty() { + return nested_sibling(focus, layout, 1); + } + // At end of paragraph — move to start of next block on the same page. let next_index = focus.paragraph_index + 1; // Verify the next block is actually on this page. - find_para_data(layout, focus.page_index, next_index)?; + find_para_data(layout, focus.page_index, next_index, &[])?; // TODO(3b-3): cross-page rightward navigation - // TODO(nested-nav): see navigate_left. Some(DocumentPosition::top_level(focus.page_index, next_index, 0)) } @@ -184,7 +239,7 @@ pub fn navigate_right( /// /// Returns `None` when already at the very first position of the document. pub fn navigate_up(focus: &DocumentPosition, layout: &PaginatedLayout) -> Option { - let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index)?; + let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index, &focus.path)?; let rect = para_data.layout.cursor_rect(focus.byte_offset)?; let margins = &layout.pages.get(focus.page_index)?.margins; @@ -229,7 +284,7 @@ pub fn navigate_down( focus: &DocumentPosition, layout: &PaginatedLayout, ) -> Option { - let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index)?; + let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index, &focus.path)?; let rect = para_data.layout.cursor_rect(focus.byte_offset)?; let margins = &layout.pages.get(focus.page_index)?.margins; let page = layout.pages.get(focus.page_index)?; @@ -271,7 +326,7 @@ pub fn navigate_home( focus: &DocumentPosition, layout: &PaginatedLayout, ) -> Option { - let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index)?; + let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index, &focus.path)?; let rect = para_data.layout.cursor_rect(focus.byte_offset)?; // Centre-y of the current line in paragraph-local coordinates. @@ -289,15 +344,15 @@ pub fn navigate_home( /// line, trimming any trailing hard-break character so the cursor stays after /// the last visible glyph. /// -/// `get_text(block_index)` is called to retrieve the paragraph text needed by +/// `get_text(path)` is called to retrieve the paragraph text needed by /// `line_end_offset` to check for a trailing `\n`. pub fn navigate_end( focus: &DocumentPosition, layout: &PaginatedLayout, - get_text: impl Fn(usize) -> String, + get_text: impl Fn(&BlockPath) -> String, ) -> Option { - let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index)?; - let text = get_text(focus.paragraph_index); + let para_data = find_para_data(layout, focus.page_index, focus.paragraph_index, &focus.path)?; + let text = get_text(&focus.block_path()); let end_offset = para_data.layout.line_end_offset(focus.byte_offset, &text)?; Some(DocumentPosition { byte_offset: end_offset, @@ -308,286 +363,5 @@ pub fn navigate_end( // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use std::sync::Arc; - - use loki_layout::{ - FontResources, LayoutColor, LayoutInsets, LayoutPage, LayoutSize, PageEditingData, - PageParagraphData, PaginatedLayout, ResolvedParaProps, StyleSpan, layout_paragraph, - }; - - use super::*; - - fn make_layout_with_text(text: &str) -> PaginatedLayout { - let mut resources = FontResources::new(); - let para = layout_paragraph( - &mut resources, - text, - &[StyleSpan { - range: 0..text.len(), - font_name: None, - font_size: 12.0, - bold: false, - weight: 400, - italic: false, - color: LayoutColor::BLACK, - underline: None, - strikethrough: None, - line_height: None, - vertical_align: None, - highlight_color: None, - letter_spacing: None, - font_variant: None, - word_spacing: None, - shadow: false, - link_url: None, - math: None, - scale: None, - kerning: None, - baseline_shift: None, - }], - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - let editing_data = PageEditingData { - paragraphs: vec![PageParagraphData { - block_index: 0, - path: Vec::new(), - layout: Arc::new(para), - origin: (0.0, 0.0), - }], - }; - let page_size = LayoutSize::new(595.0, 842.0); - let margins = LayoutInsets { - top: 72.0, - right: 72.0, - bottom: 72.0, - left: 72.0, - }; - let page = LayoutPage { - page_number: 1, - page_size, - margins, - content_items: vec![], - header_items: vec![], - footer_items: vec![], - comment_items: vec![], - header_height: 0.0, - footer_height: 0.0, - editing_data: Some(editing_data), - }; - PaginatedLayout { - page_size, - pages: vec![Arc::new(page)], - } - } - - fn focus_at(byte_offset: usize) -> DocumentPosition { - DocumentPosition::top_level(0, 0, byte_offset) - } - - #[test] - fn navigate_left_moves_to_prev_grapheme() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(3); - let result = navigate_left(&focus, &layout, |_| "hello".to_string()); - assert_eq!(result.unwrap().byte_offset, 2); - } - - #[test] - fn navigate_left_at_start_returns_none_for_first_block() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(0); - let result = navigate_left(&focus, &layout, |_| "hello".to_string()); - assert!(result.is_none(), "at start of block 0 should return None"); - } - - #[test] - fn navigate_right_moves_to_next_grapheme() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(2); - let result = navigate_right(&focus, &layout, |_| "hello".to_string()); - assert_eq!(result.unwrap().byte_offset, 3); - } - - #[test] - fn navigate_right_at_end_of_last_block_returns_none() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(5); // end of "hello" - let result = navigate_right(&focus, &layout, |_| "hello".to_string()); - assert!(result.is_none(), "at end of last block should return None"); - } - - #[test] - fn navigate_home_returns_position_on_same_paragraph() { - let layout = make_layout_with_text("hello world"); - let focus = focus_at(6); - let result = navigate_home(&focus, &layout); - // Home from mid-paragraph — offset ≤ 6 (start of line or paragraph). - let pos = result.expect("navigate_home should return Some"); - assert_eq!(pos.page_index, 0); - assert_eq!(pos.paragraph_index, 0); - assert!( - pos.byte_offset <= 6, - "Home should move to start of line (byte ≤ 6)" - ); - } - - #[test] - fn navigate_end_returns_position_on_same_paragraph() { - let layout = make_layout_with_text("hello world"); - let focus = focus_at(0); - let result = navigate_end(&focus, &layout, |_| "hello world".to_string()); - let pos = result.expect("navigate_end should return Some"); - assert_eq!(pos.page_index, 0); - assert_eq!(pos.paragraph_index, 0); - // End from start — offset should be > 0. - assert!(pos.byte_offset > 0, "End should move past the start"); - } - - #[test] - fn navigate_up_at_first_line_returns_none() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(0); - // Single-line paragraph at top of page — no line above. - let result = navigate_up(&focus, &layout); - assert!( - result.is_none(), - "no line above first line should return None" - ); - } - - #[test] - fn navigate_down_at_last_line_returns_none() { - let layout = make_layout_with_text("hello"); - let focus = focus_at(0); - // Single-line paragraph — no line below. - let result = navigate_down(&focus, &layout); - assert!( - result.is_none(), - "no line below last line should return None" - ); - } - - // ── Cross-boundary helper tests ─────────────────────────────────────────── - - fn make_two_para_layout(text0: &str, text1: &str) -> PaginatedLayout { - let mut resources = FontResources::new(); - let make_span = |text: &str| StyleSpan { - range: 0..text.len(), - font_name: None, - font_size: 12.0, - bold: false, - weight: 400, - italic: false, - color: LayoutColor::BLACK, - underline: None, - strikethrough: None, - line_height: None, - vertical_align: None, - highlight_color: None, - letter_spacing: None, - font_variant: None, - word_spacing: None, - shadow: false, - link_url: None, - math: None, - scale: None, - kerning: None, - baseline_shift: None, - }; - let para0 = layout_paragraph( - &mut resources, - text0, - &[make_span(text0)], - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - let h0 = para0.height; - let para1 = layout_paragraph( - &mut resources, - text1, - &[make_span(text1)], - &ResolvedParaProps::default(), - 400.0, - 1.0, - true, - ); - let editing_data = PageEditingData { - paragraphs: vec![ - PageParagraphData { - block_index: 0, - path: Vec::new(), - layout: Arc::new(para0), - origin: (0.0, 0.0), - }, - PageParagraphData { - block_index: 1, - path: Vec::new(), - layout: Arc::new(para1), - origin: (0.0, h0), - }, - ], - }; - let page_size = LayoutSize::new(595.0, 842.0); - let margins = LayoutInsets { - top: 72.0, - right: 72.0, - bottom: 72.0, - left: 72.0, - }; - let page = LayoutPage { - page_number: 1, - page_size, - margins, - content_items: vec![], - header_items: vec![], - footer_items: vec![], - comment_items: vec![], - header_height: 0.0, - footer_height: 0.0, - editing_data: Some(editing_data), - }; - PaginatedLayout { - page_size, - pages: vec![Arc::new(page)], - } - } - - #[test] - fn find_prev_para_data_at_block_0_returns_none() { - let layout = make_two_para_layout("first", "second"); - let result = find_prev_para_data(&layout, 0, 0); - assert!(result.is_none(), "block 0 has no predecessor"); - } - - #[test] - fn find_prev_para_data_at_block_1_returns_block_0() { - let layout = make_two_para_layout("first", "second"); - let result = find_prev_para_data(&layout, 0, 1); - let (page_idx, para) = result.expect("block 1 should have a predecessor"); - assert_eq!(page_idx, 0); - assert_eq!(para.block_index, 0); - } - - #[test] - fn find_next_para_data_at_last_block_returns_none() { - let layout = make_two_para_layout("first", "second"); - // block_index 1 is the last block; no successor. - let result = find_next_para_data(&layout, 0, 1); - assert!(result.is_none(), "last block has no successor"); - } - - #[test] - fn find_next_para_data_at_block_0_returns_block_1() { - let layout = make_two_para_layout("first", "second"); - let result = find_next_para_data(&layout, 0, 0); - let (page_idx, para) = result.expect("block 0 should have a successor"); - assert_eq!(page_idx, 0); - assert_eq!(para.block_index, 1); - } -} +#[path = "navigation_tests.rs"] +mod tests; diff --git a/loki-text/src/editing/navigation_tests.rs b/loki-text/src/editing/navigation_tests.rs new file mode 100644 index 00000000..ed40c7f5 --- /dev/null +++ b/loki-text/src/editing/navigation_tests.rs @@ -0,0 +1,385 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use std::sync::Arc; + +use loki_layout::{ + FontResources, LayoutColor, LayoutInsets, LayoutPage, LayoutSize, PageEditingData, + PageParagraphData, PaginatedLayout, ResolvedParaProps, StyleSpan, layout_paragraph, +}; + +use super::*; + +fn layout_para( + resources: &mut FontResources, + text: &str, +) -> std::sync::Arc { + Arc::new(layout_paragraph( + resources, + text, + &[StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + }], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + )) +} + +/// One page whose editing data holds the given `(block_index, path, text)` +/// paragraphs, stacked vertically. +fn make_layout_with_paras(paras: &[(usize, Vec, &str)]) -> PaginatedLayout { + let mut resources = FontResources::new(); + let mut y = 0.0f32; + let mut entries = Vec::new(); + for (block_index, path, text) in paras { + let layout = layout_para(&mut resources, text); + let h = layout.height; + entries.push(PageParagraphData { + block_index: *block_index, + path: path.clone(), + layout, + origin: (0.0, y), + }); + y += h; + } + let editing_data = PageEditingData { + paragraphs: entries, + }; + let page_size = LayoutSize::new(595.0, 842.0); + let margins = LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }; + let page = LayoutPage { + page_number: 1, + page_size, + margins, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(editing_data), + }; + PaginatedLayout { + page_size, + pages: vec![Arc::new(page)], + } +} + +fn make_layout_with_text(text: &str) -> PaginatedLayout { + make_layout_with_paras(&[(0, Vec::new(), text)]) +} + +fn focus_at(byte_offset: usize) -> DocumentPosition { + DocumentPosition::top_level(0, 0, byte_offset) +} + +#[test] +fn navigate_left_moves_to_prev_grapheme() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(3); + let result = navigate_left(&focus, &layout, |_| "hello".to_string()); + assert_eq!(result.unwrap().byte_offset, 2); +} + +#[test] +fn navigate_left_at_start_returns_none_for_first_block() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(0); + let result = navigate_left(&focus, &layout, |_| "hello".to_string()); + assert!(result.is_none(), "at start of block 0 should return None"); +} + +#[test] +fn navigate_right_moves_to_next_grapheme() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(2); + let result = navigate_right(&focus, &layout, |_| "hello".to_string()); + assert_eq!(result.unwrap().byte_offset, 3); +} + +#[test] +fn navigate_right_at_end_of_last_block_returns_none() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(5); // end of "hello" + let result = navigate_right(&focus, &layout, |_| "hello".to_string()); + assert!(result.is_none(), "at end of last block should return None"); +} + +#[test] +fn navigate_home_returns_position_on_same_paragraph() { + let layout = make_layout_with_text("hello world"); + let focus = focus_at(6); + let result = navigate_home(&focus, &layout); + // Home from mid-paragraph — offset ≤ 6 (start of line or paragraph). + let pos = result.expect("navigate_home should return Some"); + assert_eq!(pos.page_index, 0); + assert_eq!(pos.paragraph_index, 0); + assert!( + pos.byte_offset <= 6, + "Home should move to start of line (byte ≤ 6)" + ); +} + +#[test] +fn navigate_end_returns_position_on_same_paragraph() { + let layout = make_layout_with_text("hello world"); + let focus = focus_at(0); + let result = navigate_end(&focus, &layout, |_| "hello world".to_string()); + let pos = result.expect("navigate_end should return Some"); + assert_eq!(pos.page_index, 0); + assert_eq!(pos.paragraph_index, 0); + // End from start — offset should be > 0. + assert!(pos.byte_offset > 0, "End should move past the start"); +} + +#[test] +fn navigate_up_at_first_line_returns_none() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(0); + // Single-line paragraph at top of page — no line above. + let result = navigate_up(&focus, &layout); + assert!( + result.is_none(), + "no line above first line should return None" + ); +} + +#[test] +fn navigate_down_at_last_line_returns_none() { + let layout = make_layout_with_text("hello"); + let focus = focus_at(0); + // Single-line paragraph — no line below. + let result = navigate_down(&focus, &layout); + assert!( + result.is_none(), + "no line below last line should return None" + ); +} + +// ── Cross-boundary helper tests ─────────────────────────────────────────── + +fn make_two_para_layout(text0: &str, text1: &str) -> PaginatedLayout { + let mut resources = FontResources::new(); + let make_span = |text: &str| StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + font_variant: None, + word_spacing: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + }; + let para0 = layout_paragraph( + &mut resources, + text0, + &[make_span(text0)], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + ); + let h0 = para0.height; + let para1 = layout_paragraph( + &mut resources, + text1, + &[make_span(text1)], + &ResolvedParaProps::default(), + 400.0, + 1.0, + true, + ); + let editing_data = PageEditingData { + paragraphs: vec![ + PageParagraphData { + block_index: 0, + path: Vec::new(), + layout: Arc::new(para0), + origin: (0.0, 0.0), + }, + PageParagraphData { + block_index: 1, + path: Vec::new(), + layout: Arc::new(para1), + origin: (0.0, h0), + }, + ], + }; + let page_size = LayoutSize::new(595.0, 842.0); + let margins = LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }; + let page = LayoutPage { + page_number: 1, + page_size, + margins, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(editing_data), + }; + PaginatedLayout { + page_size, + pages: vec![Arc::new(page)], + } +} + +#[test] +fn find_prev_para_data_at_block_0_returns_none() { + let layout = make_two_para_layout("first", "second"); + let result = find_prev_para_data(&layout, 0, 0); + assert!(result.is_none(), "block 0 has no predecessor"); +} + +#[test] +fn find_prev_para_data_at_block_1_returns_block_0() { + let layout = make_two_para_layout("first", "second"); + let result = find_prev_para_data(&layout, 0, 1); + let (page_idx, para) = result.expect("block 1 should have a predecessor"); + assert_eq!(page_idx, 0); + assert_eq!(para.block_index, 0); +} + +#[test] +fn find_next_para_data_at_last_block_returns_none() { + let layout = make_two_para_layout("first", "second"); + // block_index 1 is the last block; no successor. + let result = find_next_para_data(&layout, 0, 1); + assert!(result.is_none(), "last block has no successor"); +} + +#[test] +fn find_next_para_data_at_block_0_returns_block_1() { + let layout = make_two_para_layout("first", "second"); + let result = find_next_para_data(&layout, 0, 0); + let (page_idx, para) = result.expect("block 0 should have a successor"); + assert_eq!(page_idx, 0); + assert_eq!(para.block_index, 1); +} + +// ── Nested (cell / note body) sibling navigation (4b.4) ─────────────────────── + +/// `[Para "intro", Table]` where the table (block 1) has one cell holding the +/// paragraphs "alpha" | "beta", followed by a top-level "outro" (block 2). +fn nested_cell_layout() -> PaginatedLayout { + make_layout_with_paras(&[ + (0, Vec::new(), "intro"), + (1, vec![PathStep::Cell { cell: 0, block: 0 }], "alpha"), + (1, vec![PathStep::Cell { cell: 0, block: 1 }], "beta"), + (2, Vec::new(), "outro"), + ]) +} + +fn nested_text(bp: &BlockPath) -> String { + match (bp.root, bp.steps.as_slice()) { + (0, []) => "intro", + (1, [PathStep::Cell { cell: 0, block: 0 }]) => "alpha", + (1, [PathStep::Cell { cell: 0, block: 1 }]) => "beta", + (2, []) => "outro", + _ => "", + } + .to_string() +} + +fn cell_pos(block: usize, byte_offset: usize) -> DocumentPosition { + DocumentPosition { + page_index: 0, + paragraph_index: 1, + byte_offset, + path: vec![PathStep::Cell { cell: 0, block }], + } +} + +#[test] +fn navigate_right_crosses_to_the_next_cell_sibling() { + let layout = nested_cell_layout(); + // End of "alpha" → start of "beta", keeping the cell path. + let result = navigate_right(&cell_pos(0, 5), &layout, nested_text); + assert_eq!(result, Some(cell_pos(1, 0))); +} + +#[test] +fn navigate_left_crosses_to_the_previous_cell_sibling() { + let layout = nested_cell_layout(); + // Start of "beta" → end of "alpha" (byte 5), keeping the cell path. + let result = navigate_left(&cell_pos(1, 0), &layout, nested_text); + assert_eq!(result, Some(cell_pos(0, 5))); +} + +#[test] +fn navigate_left_clamps_at_the_first_block_of_a_cell() { + let layout = nested_cell_layout(); + // Start of "alpha": must NOT teleport to the top-level "intro" paragraph. + assert_eq!(navigate_left(&cell_pos(0, 0), &layout, nested_text), None); +} + +#[test] +fn navigate_right_clamps_at_the_last_block_of_a_cell() { + let layout = nested_cell_layout(); + // End of "beta": must NOT teleport to the top-level "outro" paragraph. + assert_eq!(navigate_right(&cell_pos(1, 4), &layout, nested_text), None); +} + +#[test] +fn grapheme_navigation_inside_a_cell_uses_the_cell_text() { + let layout = nested_cell_layout(); + // Mid-"alpha" moves within the nested paragraph's own text (previously + // the flat getter returned the root table block's empty text). + let result = navigate_right(&cell_pos(0, 2), &layout, nested_text); + assert_eq!(result, Some(cell_pos(0, 3))); + let result = navigate_left(&cell_pos(0, 2), &layout, nested_text); + assert_eq!(result, Some(cell_pos(0, 1))); +} + +#[test] +fn navigate_end_inside_a_cell_uses_the_cell_line() { + let layout = nested_cell_layout(); + let result = navigate_end(&cell_pos(0, 1), &layout, nested_text); + assert_eq!(result, Some(cell_pos(0, 5))); +} diff --git a/loki-text/src/routes/editor/editor_keydown.rs b/loki-text/src/routes/editor/editor_keydown.rs index db5f0ff3..6078bd5d 100644 --- a/loki-text/src/routes/editor/editor_keydown.rs +++ b/loki-text/src/routes/editor/editor_keydown.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex}; use dioxus::prelude::*; use keyboard_types::Modifiers; -use loki_doc_model::loro_mutation::get_block_text; +use loki_doc_model::loro_mutation::{get_block_text, get_block_text_at}; use loki_renderer::ViewMode; use loki_renderer::render_layout::reflow_content_width_pt; @@ -146,12 +146,21 @@ pub(super) fn make_keydown_handler( | Key::End => { let shift_held = modifiers.shift(); let ldoc_guard = loro_doc.read(); + // Reflow navigation addresses top-level blocks by flat index; + // the paginated path is path-aware so navigation works inside + // table cells and note bodies too (4b.4). let get_text = |idx: usize| { ldoc_guard .as_ref() .map(|l| get_block_text(l, idx)) .unwrap_or_default() }; + let get_text_at = |bp: &loki_doc_model::BlockPath| { + ldoc_guard + .as_ref() + .map(|l| get_block_text_at(l, bp)) + .unwrap_or_default() + }; let new_pos = if view_mode() == ViewMode::Reflow { let width_px = scroll_metrics.peek().client_width; @@ -178,12 +187,12 @@ pub(super) fn make_keydown_handler( }; let Some(layout) = layout_opt else { return }; match &key { - Key::ArrowLeft => navigate_left(&focus, &layout, get_text), - Key::ArrowRight => navigate_right(&focus, &layout, get_text), + Key::ArrowLeft => navigate_left(&focus, &layout, get_text_at), + Key::ArrowRight => navigate_right(&focus, &layout, get_text_at), Key::ArrowUp => navigate_up(&focus, &layout), Key::ArrowDown => navigate_down(&focus, &layout), Key::Home => navigate_home(&focus, &layout), - Key::End => navigate_end(&focus, &layout, get_text), + Key::End => navigate_end(&focus, &layout, get_text_at), _ => None, } }; diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index d7e94202..1eaa0467 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -7,17 +7,16 @@ 1856 loki-layout/src/para.rs 1554 loki-odf/src/odt/reader/styles.rs 1494 loki-odf/src/odt/reader/document.rs -1244 loki-spreadsheet/src/routes/editor/editor_inner.rs 1209 loki-ooxml/src/docx/reader/document.rs +1130 loki-spreadsheet/src/routes/editor/editor_inner.rs 1073 loki-ooxml/src/docx/write/document.rs 978 loki-layout/src/resolve.rs 948 loki-vello/src/scene.rs -833 loki-text/src/routes/editor/editor_inner.rs +832 loki-text/src/routes/editor/editor_inner.rs 741 loki-layout/src/flow_para.rs 730 loki-ooxml/src/docx/mapper/inline.rs 651 loki-odf/src/package.rs 611 loki-ooxml/src/docx/mapper/document.rs -593 loki-text/src/editing/navigation.rs 582 loki-ooxml/src/docx/mapper/props.rs 575 loki-ooxml/src/xlsx/import.rs 491 loki-text/src/routes/editor/editor_canvas.rs @@ -27,9 +26,10 @@ 392 loki-renderer/src/document_view.rs 388 loki-ooxml/src/docx/import.rs 373 loki-layout/src/incremental.rs +367 loki-text/src/editing/navigation.rs 365 loki-odf/src/ods/export.rs -364 loki-text/src/routes/home.rs 334 loki-layout/src/lib.rs +331 loki-text/src/routes/home.rs 331 loki-text/src/editing/state.rs 328 loki-ooxml/src/docx/model/paragraph.rs 316 loki-text/src/routes/editor/editor_style.rs From c16ca6f9b734007712a1abdc5ae1fe87b0db8bbd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:40:16 +0000 Subject: [PATCH 20/27] Cross-page Left/Right and page_index recompute after mutations (4b.1) Phase 4b.1 (TODO(3b-3) tail): two related gaps in cursor/page integrity. Cross-page navigation: - navigate_left/right previously clamped at the page edge (the prev/next block was only accepted if it sat on the same page). They now use the existing find_prev/next_para_data walks, which search the whole layout, so Left at the top of a page moves to the end of the previous page's last paragraph and Right at the bottom moves to the next page's first. Entering a table block from above/below lands in its first/last cell paragraph (the entry searches return the first/last matching entry and carry its path). Stale page_index: - A DocumentPosition's page_index names the page whose editing data holds the caret's paragraph, but any relayout could invalidate it: a split pushing the new paragraph to the next page, a merge pulling one back, typing near a page boundary, or moving within a paragraph that spans pages (fragments share one ParagraphLayout with shifted origins across pages). New editing/page_locate.rs re-derives the page from the layout - for page-spanning paragraphs it picks the page whose content band contains the byte's line-centre. - Wired at both seams: set_collapsed_cursor (editor_keydown_text.rs) now places the caret after every mutation (character, Backspace merge/grapheme, selection removal, Enter split - removing the two TODO(3b-3) comments), and the paginated navigation arm re-derives the page after every move. Tests: 4 page_locate tests (including a two-page split-fragment case proving byte 0 resolves to the first page and the last byte to the second) and 4 cross-page navigation tests. navigation.rs's lookup helpers moved to navigation_find.rs, taking navigation.rs out of the file-ceiling baseline (34 -> 33 entries). Remaining 3b-3 tail (noted in plan/audit): the double-Enter list-exit heuristic (clear_para_props). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 2 +- loki-text/src/editing/mod.rs | 2 + loki-text/src/editing/navigation.rs | 183 ++++-------------- loki-text/src/editing/navigation_find.rs | 122 ++++++++++++ loki-text/src/editing/navigation_tests.rs | 97 ++++++++++ loki-text/src/editing/page_locate.rs | 86 ++++++++ loki-text/src/editing/page_locate_tests.rs | 156 +++++++++++++++ loki-text/src/routes/editor/editor_keydown.rs | 3 + .../src/routes/editor/editor_keydown_ctrl.rs | 11 +- .../src/routes/editor/editor_keydown_text.rs | 48 +++-- scripts/file-ceiling-baseline.txt | 1 - 12 files changed, 545 insertions(+), 168 deletions(-) create mode 100644 loki-text/src/editing/navigation_find.rs create mode 100644 loki-text/src/editing/page_locate.rs create mode 100644 loki-text/src/editing/page_locate_tests.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index d7d7b4ee..2fdf0724 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -42,7 +42,7 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | Topic | file:line(s) | Defers what | |---|---|---| -| `3b-3` (partial) | `navigation.rs` (many) | Cross-page nav: up/down works; **left/right at page edges + `page_index` recompute after split/merge still `None`** | +| `3b-3` (partial) | `navigation.rs` (many) | ~~Left/right at page edges + `page_index` recompute after split/merge~~ **fixed 2026-07-05** (plan 4b.1: cross-page Left/Right + `page_locate::recompute_page_index` after every mutation/move). Remaining: double-Enter list-exit heuristic (`clear_para_props`) | | `loro-bridge` | `loro_bridge/decode.rs` (borders), `table.rs:25` | ~~Non-Rgb colors, comment/bookmark anchors, quote/span attrs~~ **fixed 2026-07-04** (plan Phase 1.4). Remaining: non-Rgb *border* colors (format migration), `Cite` metadata, structural-table CRDT semantics | | `loro-compaction` | `loki-bench/benches/leak_loro_history.rs`; bridge | ~~Compact the CRDT oplog~~ **fixed 2026-07-04** (plan Phase 1.5): `loro_bridge::compact` + save-point wiring in loki-text; bench asserts the flattened curve. On-device validation pending (BM-14) | | `omml` | `docx/omml/mod.rs:20` | OMML↔MathML for delimiters, n-ary, matrices, accents | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 3964cc52..99d02661 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -116,7 +116,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | Task | Topic | Detail | Effort | |---|---|---|---| -| 4b.1 | `3b-3` | Finish cross-page navigation: left/right at page edges; `page_index` recompute after split/merge (currently `None`). | M | +| 4b.1 | `3b-3` | ✅ **Done 2026-07-05** — (1) Left/Right cross page boundaries (the prev/next-entry searches walk the whole layout; entering a table from above/below lands in its first/last cell). (2) New `editing/page_locate.rs`: `recompute_page_index` re-derives a position's page from the layout, picking the page whose content band shows the byte's line for page-spanning paragraphs; wired into every mutation caret placement (`set_collapsed_cursor` — split, merge, typing, selection removal) and after every navigation move, replacing the stale-page TODOs. 4 page-locate tests (incl. a split-fragment band test) + 4 cross-page nav tests; `navigation.rs` split (`navigation_find.rs`) dropped it from the ceiling baseline (34 → 33). **Remaining `3b-3` tail:** the double-Enter list-exit heuristic (`clear_para_props`). | M | | 4b.2 | `formatting` | Multi-block-selection formatting (today clamped to the focus paragraph) — `editor_formatting.rs:106`. | M | | 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Remaining tail:** Spec 01's typed `SaveError` residual; disabling the ribbon Save button when clean (needs the dirty flag threaded into the ribbon). | M | | 4b.4 | `nested-nav` | ✅ **Done 2026-07-05** — paginated navigation is path-aware end-to-end: `find_para_data` matches `(block_index, path)` (index alone returned the first cell's entry for every table paragraph), the `get_text` closures take a `BlockPath` (grapheme moves inside cells previously read the root block's empty text), and Left/Right at a nested paragraph's edges cross to the sibling within the same cell / note body, clamping at the container's first/last block. Inline tests extracted to `navigation_tests.rs` (`#[path]` idiom, file ratcheted 593 → 367) + 6 nested-nav regression tests. Reflow navigation stays top-level-only (tables have no reflow editing data). | S | diff --git a/loki-text/src/editing/mod.rs b/loki-text/src/editing/mod.rs index 2d0efbf8..575799ba 100644 --- a/loki-text/src/editing/mod.rs +++ b/loki-text/src/editing/mod.rs @@ -12,6 +12,8 @@ pub mod cursor; pub mod hit_test; pub mod navigation; +mod navigation_find; +pub mod page_locate; pub mod reflow_nav; pub mod relayout; pub mod saved_state; diff --git a/loki-text/src/editing/navigation.rs b/loki-text/src/editing/navigation.rs index 1217d966..69a9357d 100644 --- a/loki-text/src/editing/navigation.rs +++ b/loki-text/src/editing/navigation.rs @@ -9,10 +9,10 @@ //! //! # Cross-page navigation //! -//! All six navigation functions clamp at the current page boundary and return -//! `None` when the target falls off the page. The caller leaves the cursor -//! unchanged on `None`. Cross-page traversal is tracked as -//! `TODO(3b-3)` throughout this file. +//! Left/Right cross page boundaries (the prev/next-entry searches walk the +//! whole layout); Up/Down cross via their previous/next-paragraph fallback. +//! `None` means there is nowhere to go (document start/end) and the caller +//! leaves the cursor unchanged. //! //! # Double-Enter to exit a list //! @@ -22,125 +22,23 @@ //! the list properties from the trailing empty block. //! `TODO(3b-3)`: implement `clear_para_props` / list-exit heuristic. -use loki_doc_model::{BlockPath, PathStep}; -use loki_layout::{PageParagraphData, PaginatedLayout}; +use loki_doc_model::BlockPath; +use loki_layout::PaginatedLayout; use super::cursor::{DocumentPosition, next_grapheme_boundary, prev_grapheme_boundary}; use super::hit_test::hit_test_page; - -// ── Internal helpers ────────────────────────────────────────────────────────── - -/// Look up the [`PageParagraphData`] for a paragraph on a specific page. -/// -/// `block_index` is the flat document block index stored in -/// [`DocumentPosition::paragraph_index`]; `path` is the nested descent -/// ([`DocumentPosition::path`]). Nested paragraphs (table cells, note bodies) -/// share their root's `block_index`, so both must match — matching on the -/// index alone would return the first cell's entry for *every* paragraph of -/// a table. -fn find_para_data<'a>( - layout: &'a PaginatedLayout, - page_index: usize, - block_index: usize, - path: &[PathStep], -) -> Option<&'a PageParagraphData> { - layout - .pages - .get(page_index)? - .editing_data - .as_ref()? - .paragraphs - .iter() - .find(|p| p.block_index == block_index && p.path == path) -} - -/// The leaf block index of a nested position within its container (the leaf -/// step's block index; positions with an empty path are not nested). -fn nested_leaf(focus: &DocumentPosition) -> Option { - match focus.path.last() { - Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => Some(*block), - None => None, - } -} - -/// The sibling of a nested `focus` shifted by `delta` blocks within its -/// container, if it exists in the page's layout. Clamps at the container's -/// first/last block by returning `None` — crossing out of a cell or note body -/// into the surrounding document is a separate feature. -fn nested_sibling( - focus: &DocumentPosition, - layout: &PaginatedLayout, - delta: isize, -) -> Option { - let leaf = nested_leaf(focus)?; - leaf.checked_add_signed(delta)?; // clamp at the container start - let sibling = focus.sibling_block(delta, 0); - // Only cross when the sibling paragraph actually exists in the layout. - find_para_data( - layout, - sibling.page_index, - sibling.paragraph_index, - &sibling.path, - )?; - Some(sibling) -} - -/// Find the paragraph entry immediately preceding `block_index` in document order. -/// -/// Searches the current page first (for `block_index - 1`), then walks backward -/// through previous pages. Returns `(page_index, para_data)`. -/// -/// Returns `None` when `block_index` is 0 (no predecessor exists). -fn find_prev_para_data( - layout: &PaginatedLayout, - page_index: usize, - block_index: usize, -) -> Option<(usize, &PageParagraphData)> { - if block_index == 0 { - return None; - } - let prev_block = block_index - 1; - // Search this page and previous pages for the entry with prev_block. - for pi in (0..=page_index).rev() { - if let Some(ed) = layout.pages[pi].editing_data.as_ref() - && let Some(para) = ed.paragraphs.iter().find(|p| p.block_index == prev_block) - { - return Some((pi, para)); - } - } - None -} - -/// Find the paragraph entry immediately following `block_index` in document order. -/// -/// Searches the current page first (for `block_index + 1`), then walks forward -/// through subsequent pages. Returns `(page_index, para_data)`. -/// -/// Returns `None` when no following block exists in the layout. -fn find_next_para_data( - layout: &PaginatedLayout, - page_index: usize, - block_index: usize, -) -> Option<(usize, &PageParagraphData)> { - let next_block = block_index + 1; - for pi in page_index..layout.pages.len() { - if let Some(ed) = layout.pages[pi].editing_data.as_ref() - && let Some(para) = ed.paragraphs.iter().find(|p| p.block_index == next_block) - { - return Some((pi, para)); - } - } - None -} +use super::navigation_find::{ + find_next_para_data, find_para_data, find_prev_para_data, nested_sibling, +}; // ── Public navigation functions ─────────────────────────────────────────────── /// Move one grapheme cluster to the left. /// /// - Within a paragraph: moves to the previous grapheme boundary. -/// - At offset 0: moves to the end of the previous paragraph **on the same -/// page** — the previous sibling within the same container for a nested -/// position (table cell / note body), which clamps at the container's +/// - At offset 0: moves to the end of the previous paragraph, crossing page +/// boundaries — or, for a nested position (table cell / note body), to the +/// previous sibling within the same container, clamping at the container's /// first block. /// /// Returns `None` when already at the very start (first block on first page) @@ -148,9 +46,6 @@ fn find_next_para_data( /// /// `get_text(path)` is called to retrieve the plain text of the addressed /// paragraph. Pass a closure over [`loki_doc_model::get_block_text_at`]. -/// -/// `TODO(3b-3)`: when at offset 0 of the first block on a page, navigate to -/// the last character of the last block on the previous page. pub fn navigate_left( focus: &DocumentPosition, layout: &PaginatedLayout, @@ -176,32 +71,32 @@ pub fn navigate_left( }); } - // At start of paragraph — move to end of previous block on the same page. - if focus.paragraph_index == 0 { - // TODO(3b-3): navigate to last char of last block on previous page - return None; - } - let prev_index = focus.paragraph_index - 1; - // Verify the previous block is actually on this page before crossing. - find_para_data(layout, focus.page_index, prev_index, &[])?; - let prev_text = get_text(&BlockPath::block(prev_index)); - Some(DocumentPosition::top_level( - focus.page_index, - prev_index, - prev_text.len(), - )) + // At start of paragraph — move to the end of the previous block, + // crossing page boundaries (the entry search walks backward through the + // layout, so the nearest fragment page wins for split paragraphs). + let (prev_pi, prev_para) = + find_prev_para_data(layout, focus.page_index, focus.paragraph_index)?; + let prev_pos = DocumentPosition { + page_index: prev_pi, + paragraph_index: prev_para.block_index, + byte_offset: 0, + path: prev_para.path.clone(), + }; + let prev_text = get_text(&prev_pos.block_path()); + Some(DocumentPosition { + byte_offset: prev_text.len(), + ..prev_pos + }) } /// Move one grapheme cluster to the right. /// /// - Within a paragraph: moves to the next grapheme boundary. -/// - At the end: moves to the start of the next paragraph **on the same page** -/// — the next sibling within the same container for a nested position, -/// which clamps at the container's last block. +/// - At the end: moves to the start of the next paragraph, crossing page +/// boundaries — or, for a nested position, to the next sibling within the +/// same container, clamping at the container's last block. /// /// Returns `None` when already at the very end or no layout data is available. -/// -/// `TODO(3b-3)`: cross-page rightward navigation. pub fn navigate_right( focus: &DocumentPosition, layout: &PaginatedLayout, @@ -222,12 +117,16 @@ pub fn navigate_right( return nested_sibling(focus, layout, 1); } - // At end of paragraph — move to start of next block on the same page. - let next_index = focus.paragraph_index + 1; - // Verify the next block is actually on this page. - find_para_data(layout, focus.page_index, next_index, &[])?; - // TODO(3b-3): cross-page rightward navigation - Some(DocumentPosition::top_level(focus.page_index, next_index, 0)) + // At end of paragraph — move to the start of the next block, crossing + // page boundaries (the entry search walks forward through the layout). + let (next_pi, next_para) = + find_next_para_data(layout, focus.page_index, focus.paragraph_index)?; + Some(DocumentPosition { + page_index: next_pi, + paragraph_index: next_para.block_index, + byte_offset: 0, + path: next_para.path.clone(), + }) } /// Move the cursor one line up, preserving the horizontal screen position. diff --git a/loki-text/src/editing/navigation_find.rs b/loki-text/src/editing/navigation_find.rs new file mode 100644 index 00000000..9561793b --- /dev/null +++ b/loki-text/src/editing/navigation_find.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Paragraph-entry lookup helpers for [`super::navigation`]: path-aware +//! entry search, nested-sibling resolution, and the cross-page prev/next +//! walks. Extracted to keep `navigation.rs` under the 300-line ceiling. + +use loki_doc_model::PathStep; +use loki_layout::{PageParagraphData, PaginatedLayout}; + +use super::cursor::DocumentPosition; + +/// Look up the [`PageParagraphData`] for a paragraph on a specific page. +/// +/// `block_index` is the flat document block index stored in +/// [`DocumentPosition::paragraph_index`]; `path` is the nested descent +/// ([`DocumentPosition::path`]). Nested paragraphs (table cells, note bodies) +/// share their root's `block_index`, so both must match — matching on the +/// index alone would return the first cell's entry for *every* paragraph of +/// a table. +pub(super) fn find_para_data<'a>( + layout: &'a PaginatedLayout, + page_index: usize, + block_index: usize, + path: &[PathStep], +) -> Option<&'a PageParagraphData> { + layout + .pages + .get(page_index)? + .editing_data + .as_ref()? + .paragraphs + .iter() + .find(|p| p.block_index == block_index && p.path == path) +} + +/// The leaf block index of a nested position within its container (the leaf +/// step's block index; positions with an empty path are not nested). +pub(super) fn nested_leaf(focus: &DocumentPosition) -> Option { + match focus.path.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => Some(*block), + None => None, + } +} + +/// The sibling of a nested `focus` shifted by `delta` blocks within its +/// container, if it exists in the page's layout. Clamps at the container's +/// first/last block by returning `None` — crossing out of a cell or note body +/// into the surrounding document is a separate feature. +pub(super) fn nested_sibling( + focus: &DocumentPosition, + layout: &PaginatedLayout, + delta: isize, +) -> Option { + let leaf = nested_leaf(focus)?; + leaf.checked_add_signed(delta)?; // clamp at the container start + let sibling = focus.sibling_block(delta, 0); + // Only cross when the sibling paragraph actually exists in the layout. + find_para_data( + layout, + sibling.page_index, + sibling.paragraph_index, + &sibling.path, + )?; + Some(sibling) +} + +/// Find the paragraph entry immediately preceding `block_index` in document order. +/// +/// Searches the current page first (for `block_index - 1`), then walks backward +/// through previous pages. When the preceding block is a container (a table), +/// its **last** paragraph entry on the page is returned, so entering it from +/// below lands in its final cell. Returns `(page_index, para_data)`. +/// +/// Returns `None` when `block_index` is 0 (no predecessor exists). +pub(super) fn find_prev_para_data( + layout: &PaginatedLayout, + page_index: usize, + block_index: usize, +) -> Option<(usize, &PageParagraphData)> { + if block_index == 0 { + return None; + } + let prev_block = block_index - 1; + // Search this page and previous pages for the entry with prev_block. + for pi in (0..=page_index.min(layout.pages.len().saturating_sub(1))).rev() { + if let Some(ed) = layout.pages[pi].editing_data.as_ref() + && let Some(para) = ed + .paragraphs + .iter() + .rev() + .find(|p| p.block_index == prev_block) + { + return Some((pi, para)); + } + } + None +} + +/// Find the paragraph entry immediately following `block_index` in document order. +/// +/// Searches the current page first (for `block_index + 1`), then walks forward +/// through subsequent pages. When the following block is a container (a +/// table), its **first** paragraph entry is returned, so entering it from +/// above lands in its first cell. Returns `(page_index, para_data)`. +/// +/// Returns `None` when no following block exists in the layout. +pub(super) fn find_next_para_data( + layout: &PaginatedLayout, + page_index: usize, + block_index: usize, +) -> Option<(usize, &PageParagraphData)> { + let next_block = block_index + 1; + for pi in page_index..layout.pages.len() { + if let Some(ed) = layout.pages[pi].editing_data.as_ref() + && let Some(para) = ed.paragraphs.iter().find(|p| p.block_index == next_block) + { + return Some((pi, para)); + } + } + None +} diff --git a/loki-text/src/editing/navigation_tests.rs b/loki-text/src/editing/navigation_tests.rs index ed40c7f5..974c3881 100644 --- a/loki-text/src/editing/navigation_tests.rs +++ b/loki-text/src/editing/navigation_tests.rs @@ -3,6 +3,8 @@ use std::sync::Arc; +use loki_doc_model::PathStep; + use loki_layout::{ FontResources, LayoutColor, LayoutInsets, LayoutPage, LayoutSize, PageEditingData, PageParagraphData, PaginatedLayout, ResolvedParaProps, StyleSpan, layout_paragraph, @@ -383,3 +385,98 @@ fn navigate_end_inside_a_cell_uses_the_cell_line() { let result = navigate_end(&cell_pos(0, 1), &layout, nested_text); assert_eq!(result, Some(cell_pos(0, 5))); } + +// ── Cross-page Left/Right (4b.1 / 3b-3) ─────────────────────────────────────── + +/// Two pages: block 0 ("first page") on page 0, block 1 ("second") on page 1. +fn two_page_layout() -> PaginatedLayout { + let mut resources = FontResources::new(); + let page_size = LayoutSize::new(595.0, 842.0); + let margins = LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }; + let mk_page = |number: usize, paras: Vec| LayoutPage { + page_number: number, + page_size, + margins, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(PageEditingData { paragraphs: paras }), + }; + let p0 = layout_para(&mut resources, "first page"); + let p1 = layout_para(&mut resources, "second"); + PaginatedLayout { + page_size, + pages: vec![ + Arc::new(mk_page( + 1, + vec![PageParagraphData { + block_index: 0, + path: Vec::new(), + layout: p0, + origin: (0.0, 0.0), + }], + )), + Arc::new(mk_page( + 2, + vec![PageParagraphData { + block_index: 1, + path: Vec::new(), + layout: p1, + origin: (0.0, 0.0), + }], + )), + ], + } +} + +fn two_page_text(bp: &BlockPath) -> String { + match bp.root { + 0 => "first page", + 1 => "second", + _ => "", + } + .to_string() +} + +#[test] +fn navigate_right_crosses_to_the_next_page() { + let layout = two_page_layout(); + // End of block 0 (page 0) → start of block 1 on page 1. + let focus = DocumentPosition::top_level(0, 0, "first page".len()); + let result = navigate_right(&focus, &layout, two_page_text); + assert_eq!(result, Some(DocumentPosition::top_level(1, 1, 0))); +} + +#[test] +fn navigate_left_crosses_to_the_previous_page() { + let layout = two_page_layout(); + // Start of block 1 (page 1) → end of block 0 on page 0. + let focus = DocumentPosition::top_level(1, 1, 0); + let result = navigate_left(&focus, &layout, two_page_text); + assert_eq!( + result, + Some(DocumentPosition::top_level(0, 0, "first page".len())) + ); +} + +#[test] +fn navigate_left_at_document_start_still_returns_none() { + let layout = two_page_layout(); + let focus = DocumentPosition::top_level(0, 0, 0); + assert_eq!(navigate_left(&focus, &layout, two_page_text), None); +} + +#[test] +fn navigate_right_at_document_end_still_returns_none() { + let layout = two_page_layout(); + let focus = DocumentPosition::top_level(1, 1, "second".len()); + assert_eq!(navigate_right(&focus, &layout, two_page_text), None); +} diff --git a/loki-text/src/editing/page_locate.rs b/loki-text/src/editing/page_locate.rs new file mode 100644 index 00000000..2f7abd54 --- /dev/null +++ b/loki-text/src/editing/page_locate.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Re-deriving a cursor position's `page_index` from the paginated layout +//! (plan 4b.1 / `3b-3`). +//! +//! A [`DocumentPosition`]'s `page_index` must name the page whose editing +//! data contains the caret's paragraph — hit-testing, cursor painting, and +//! navigation all resolve the paragraph through it. Two things silently +//! invalidate it: +//! +//! - **mutations**: a split/merge or typing near a page boundary relays the +//! document out, and the caret's paragraph can move to a different page; +//! - **split paragraphs**: a paragraph flowing across a page break has an +//! entry on *every* page it touches (one shared [`ParagraphLayout`] with +//! shifted origins), so the right page depends on the byte offset's line. +//! +//! [`recompute_page_index`] handles both: it scans the layout for the pages +//! holding the position's `(block_index, path)` entry and, when the +//! paragraph spans several pages, picks the page whose content band contains +//! the byte's line. +//! +//! [`ParagraphLayout`]: loki_layout::ParagraphLayout + +use loki_layout::PaginatedLayout; + +use super::cursor::DocumentPosition; + +#[cfg(test)] +#[path = "page_locate_tests.rs"] +mod tests; + +/// Returns `pos` with its `page_index` re-derived from `layout`. +/// +/// When the position's paragraph is found on exactly one page, that page +/// wins. When it spans several pages (a split paragraph), the page whose +/// content band contains the byte offset's line-centre wins; if no band +/// matches (degenerate geometry), the first page holding the paragraph is +/// used. When the paragraph is not in the layout at all (e.g. the layout is +/// momentarily stale), `pos` is returned unchanged. +#[must_use] +pub fn recompute_page_index(layout: &PaginatedLayout, pos: &DocumentPosition) -> DocumentPosition { + let mut first_holder: Option = None; + let mut visible: Option = None; + + for (pi, page) in layout.pages.iter().enumerate() { + let Some(ed) = page.editing_data.as_ref() else { + continue; + }; + let Some(para) = ed + .paragraphs + .iter() + .find(|p| p.block_index == pos.paragraph_index && p.path == pos.path) + else { + continue; + }; + first_holder.get_or_insert(pi); + if visible.is_none() + && let Some(rect) = para.layout.cursor_rect(pos.byte_offset) + { + // Content-band check: the line's centre, in page-content + // coordinates (origin is already content-relative). + let y_center = rect.y + rect.height / 2.0 + para.origin.1; + let content_h = page.page_size.height - page.margins.top - page.margins.bottom; + if y_center >= 0.0 && y_center < content_h { + visible = Some(pi); + } + } + if visible.is_some() { + break; + } + } + + let new_page = match (visible, first_holder) { + (Some(pi), _) => pi, + (None, Some(pi)) => pi, + (None, None) => pos.page_index, + }; + if new_page == pos.page_index { + return pos.clone(); + } + DocumentPosition { + page_index: new_page, + ..pos.clone() + } +} diff --git a/loki-text/src/editing/page_locate_tests.rs b/loki-text/src/editing/page_locate_tests.rs new file mode 100644 index 00000000..d78971a9 --- /dev/null +++ b/loki-text/src/editing/page_locate_tests.rs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use std::sync::Arc; + +use loki_layout::{ + FontResources, LayoutColor, LayoutInsets, LayoutPage, LayoutSize, PageEditingData, + PageParagraphData, PaginatedLayout, ParagraphLayout, ResolvedParaProps, StyleSpan, + layout_paragraph, +}; + +use super::*; + +const PAGE_H: f32 = 842.0; +const MARGIN: f32 = 72.0; +const CONTENT_H: f32 = PAGE_H - 2.0 * MARGIN; + +fn para(text: &str, width: f32) -> Arc { + let mut resources = FontResources::new(); + Arc::new(layout_paragraph( + &mut resources, + text, + &[StyleSpan { + range: 0..text.len(), + font_name: None, + font_size: 12.0, + bold: false, + weight: 400, + italic: false, + color: LayoutColor::BLACK, + underline: None, + strikethrough: None, + line_height: None, + vertical_align: None, + highlight_color: None, + letter_spacing: None, + word_spacing: None, + font_variant: None, + shadow: false, + link_url: None, + math: None, + scale: None, + kerning: None, + baseline_shift: None, + }], + &ResolvedParaProps::default(), + width, + 1.0, + true, + )) +} + +fn page(paragraphs: Vec, number: usize) -> Arc { + Arc::new(LayoutPage { + page_number: number, + page_size: LayoutSize::new(595.0, PAGE_H), + margins: LayoutInsets { + top: MARGIN, + right: MARGIN, + bottom: MARGIN, + left: MARGIN, + }, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(PageEditingData { paragraphs }), + }) +} + +fn entry(block_index: usize, layout: Arc, y: f32) -> PageParagraphData { + PageParagraphData { + block_index, + path: Vec::new(), + layout, + origin: (0.0, y), + } +} + +#[test] +fn moves_the_page_index_to_the_paragraphs_page() { + // Block 0 on page 0, block 1 on page 1 — a position for block 1 that + // still carries page 0 (e.g. after a split pushed it over) is corrected. + let p0 = para("first", 400.0); + let p1 = para("second", 400.0); + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![ + page(vec![entry(0, p0, 0.0)], 1), + page(vec![entry(1, p1, 0.0)], 2), + ], + }; + let stale = DocumentPosition::top_level(0, 1, 0); + let fixed = recompute_page_index(&layout, &stale); + assert_eq!(fixed.page_index, 1); + assert_eq!(fixed.paragraph_index, 1); +} + +#[test] +fn keeps_the_page_index_when_already_correct() { + let p0 = para("only", 400.0); + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![page(vec![entry(0, p0, 0.0)], 1)], + }; + let pos = DocumentPosition::top_level(0, 0, 2); + assert_eq!(recompute_page_index(&layout, &pos).page_index, 0); +} + +#[test] +fn unknown_paragraph_leaves_the_position_unchanged() { + let p0 = para("only", 400.0); + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![page(vec![entry(0, p0, 0.0)], 1)], + }; + let pos = DocumentPosition::top_level(0, 9, 0); + assert_eq!(recompute_page_index(&layout, &pos), pos); +} + +#[test] +fn split_paragraph_picks_the_page_showing_the_bytes_line() { + // One long paragraph wrapped to many lines at a narrow measure, split + // across two pages the way the flow engine does it: the same layout on + // both pages, page 1's fragment shifted up so its visible band starts + // where page 0's ended. + let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; + let p = para(text, 60.0); // narrow → many lines + let total_h = p.height; + assert!( + total_h > 40.0, + "test premise: the paragraph wraps to several lines (height {total_h})" + ); + // Page 0 shows the first half, page 1 the rest. + let cut = total_h / 2.0; + let layout = PaginatedLayout { + page_size: LayoutSize::new(595.0, PAGE_H), + pages: vec![ + // Fragment on page 0 ends its visible band at `cut`: place it so + // the paragraph starts at the bottom of the content area minus cut. + page(vec![entry(0, Arc::clone(&p), CONTENT_H - cut)], 1), + // Fragment on page 1 starts `cut` into the paragraph. + page(vec![entry(0, Arc::clone(&p), -cut)], 2), + ], + }; + + // Byte 0 (first line) is visible on page 0 only. + let first = recompute_page_index(&layout, &DocumentPosition::top_level(1, 0, 0)); + assert_eq!(first.page_index, 0); + + // The last byte (last line) is visible on page 1 only. + let last = recompute_page_index(&layout, &DocumentPosition::top_level(0, 0, text.len())); + assert_eq!(last.page_index, 1); +} diff --git a/loki-text/src/routes/editor/editor_keydown.rs b/loki-text/src/routes/editor/editor_keydown.rs index 6078bd5d..87e208a7 100644 --- a/loki-text/src/routes/editor/editor_keydown.rs +++ b/loki-text/src/routes/editor/editor_keydown.rs @@ -195,6 +195,9 @@ pub(super) fn make_keydown_handler( Key::End => navigate_end(&focus, &layout, get_text_at), _ => None, } + // A move inside a page-spanning paragraph can land on a + // line shown on a different page — re-derive the page. + .map(|np| crate::editing::page_locate::recompute_page_index(&layout, &np)) }; if let Some(np) = new_pos { diff --git a/loki-text/src/routes/editor/editor_keydown_ctrl.rs b/loki-text/src/routes/editor/editor_keydown_ctrl.rs index e0e786bc..7df91a4d 100644 --- a/loki-text/src/routes/editor/editor_keydown_ctrl.rs +++ b/loki-text/src/routes/editor/editor_keydown_ctrl.rs @@ -185,7 +185,7 @@ pub(super) fn handle_enter_key( focus: DocumentPosition, loro_doc: Signal>, doc_state: &Arc>, - mut cursor_state: Signal, + cursor_state: Signal, undo_manager: Signal>, can_undo: Signal, can_redo: Signal, @@ -233,13 +233,12 @@ pub(super) fn handle_enter_key( can_undo, can_redo, ); - // TODO(3b-3): recompute page_index from layout after split. // The split inserts the new block right after the source within the same - // container, so the caret moves to the next sibling block at offset 0. + // container, so the caret moves to the next sibling block at offset 0 + // (its page_index is re-derived from the fresh layout — the new + // paragraph may start on the next page). let new_pos = focus.sibling_block(1, 0); - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); + super::editor_keydown_text::set_collapsed_cursor(doc_state, cursor_state, new_pos); } /// Syncs cursor generation, `can_undo`, and `can_redo` after any document mutation. diff --git a/loki-text/src/routes/editor/editor_keydown_text.rs b/loki-text/src/routes/editor/editor_keydown_text.rs index a12ddbd6..abe0fc35 100644 --- a/loki-text/src/routes/editor/editor_keydown_text.rs +++ b/loki-text/src/routes/editor/editor_keydown_text.rs @@ -21,6 +21,28 @@ use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; #[path = "editor_keydown_text_tests.rs"] mod tests; +/// Collapses the cursor to `pos` after a mutation, re-deriving its +/// `page_index` from the freshly relaid-out layout — a split, merge, or +/// keystroke near a page boundary can move the caret's paragraph to a +/// different page (plan 4b.1). +pub(super) fn set_collapsed_cursor( + doc_state: &Arc>, + mut cursor_state: Signal, + pos: DocumentPosition, +) { + let layout = doc_state + .lock() + .ok() + .and_then(|s| s.paginated_layout.clone()); + let pos = match layout { + Some(l) => crate::editing::page_locate::recompute_page_index(&l, &pos), + None => pos, + }; + let mut cs = cursor_state.write(); + cs.focus = Some(pos.clone()); + cs.anchor = Some(pos); +} + /// What [`remove_selection`] did with the active selection. pub(super) enum SelectionRemoval { /// No range selection was active — the caller performs its normal @@ -81,7 +103,7 @@ fn delete_selection_in_doc(ldoc: &loro::LoroDoc, cursor: &CursorState) -> Option pub(super) fn remove_selection( loro_doc: Signal>, doc_state: &Arc>, - mut cursor_state: Signal, + cursor_state: Signal, undo_manager: Signal>, can_undo: Signal, can_redo: Signal, @@ -108,9 +130,7 @@ pub(super) fn remove_selection( can_undo, can_redo, ); - let mut cs = cursor_state.write(); - cs.focus = Some(collapsed.clone()); - cs.anchor = Some(collapsed); + set_collapsed_cursor(doc_state, cursor_state, collapsed); SelectionRemoval::Removed } @@ -125,7 +145,7 @@ pub(super) fn handle_character_key( focus: DocumentPosition, loro_doc: Signal>, doc_state: &Arc>, - mut cursor_state: Signal, + cursor_state: Signal, undo_manager: Signal>, can_undo: Signal, can_redo: Signal, @@ -162,9 +182,7 @@ pub(super) fn handle_character_key( byte_offset: insert_at.byte_offset + ch.len(), ..insert_at }; - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); + set_collapsed_cursor(doc_state, cursor_state, new_pos); } /// Handles Backspace: removes the active selection, or merges with the @@ -174,7 +192,7 @@ pub(super) fn handle_backspace_key( focus: DocumentPosition, loro_doc: Signal>, doc_state: &Arc>, - mut cursor_state: Signal, + cursor_state: Signal, undo_manager: Signal>, can_undo: Signal, can_redo: Signal, @@ -215,12 +233,10 @@ pub(super) fn handle_backspace_key( can_undo, can_redo, ); - // TODO(3b-3): recompute page_index from layout after merge. - // Caret lands at the join point in the previous sibling block. + // Caret lands at the join point in the previous sibling block (its + // page_index is re-derived from the merged layout). let new_pos = focus.sibling_block(-1, merged_offset); - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); + set_collapsed_cursor(doc_state, cursor_state, new_pos); return; } let prev = { @@ -249,7 +265,5 @@ pub(super) fn handle_backspace_key( byte_offset: prev, ..focus }; - let mut cs = cursor_state.write(); - cs.focus = Some(new_pos.clone()); - cs.anchor = Some(new_pos); + set_collapsed_cursor(doc_state, cursor_state, new_pos); } diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index 1eaa0467..83749220 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -26,7 +26,6 @@ 392 loki-renderer/src/document_view.rs 388 loki-ooxml/src/docx/import.rs 373 loki-layout/src/incremental.rs -367 loki-text/src/editing/navigation.rs 365 loki-odf/src/ods/export.rs 334 loki-layout/src/lib.rs 331 loki-text/src/routes/home.rs From 2d9f5847e188708ace76371387146373f9a7a0b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:43:49 +0000 Subject: [PATCH 21/27] Multi-block selection formatting (4b.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b.2 (TODO(formatting)): the six character-formatting toggles (bold, italic, underline, strikethrough, superscript, subscript) previously clamped a selection spanning several paragraphs to the focus paragraph. - New editor_format_range.rs: resolve_format_ranges maps the cursor state to one (BlockPath, byte_start, byte_end) per paragraph — the selection's tail of the first paragraph, every middle paragraph in full, and the head of the last, for both top-level runs and blocks within one table cell / note body (the sibling ordering mirrors the model layer's delete_selection_at). Text-less blocks inside the range (a table between two paragraphs) contribute an empty range and are skipped, leaving their nested content untouched. Cross-container selections keep the previous clamp-to-focus rule; point cursors keep word expansion. resolve_format_range (singular, used by hyperlink insertion) is now defined as the first range. - editor_formatting.rs toggles loop over the ranges: the mark state at the document-order start of the selection decides apply-vs-clear and the whole selection is made uniform, matching Word/LibreOffice. 8 tests: range resolution (single/multi paragraph, reversed endpoints, offset-0 end skipped, in-cell spans, cross-container clamp, table-skipping) plus an end-to-end toggle_bold double-toggle across three paragraphs. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-audit-2026-07-04.md | 2 +- docs/deferred-features-plan-2026-07-04.md | 2 +- .../src/routes/editor/editor_format_range.rs | 197 +++++++++++++++ .../editor/editor_format_range_tests.rs | 230 ++++++++++++++++++ .../src/routes/editor/editor_formatting.rs | 163 ++++--------- loki-text/src/routes/editor/editor_insert.rs | 2 +- loki-text/src/routes/editor/mod.rs | 1 + 7 files changed, 478 insertions(+), 119 deletions(-) create mode 100644 loki-text/src/routes/editor/editor_format_range.rs create mode 100644 loki-text/src/routes/editor/editor_format_range_tests.rs diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index 2fdf0724..c3b77330 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -61,7 +61,7 @@ All are genuine, mostly upstream-gated (Parley/Blitz/Vello) or deliberately defe | `pdf-rotate` | `pdf/src/page.rs:83` | Rotation transform in PDF export | | `odf-master-page` | `odf/reader/styles.rs:200` | ODF master-page transitions | | `odt-fidelity` | `editor_load.rs:84,88` | Tracked DOCX/ODT import gaps | -| `formatting` | `editor_formatting.rs:106` | Multi-block-selection formatting (clamped to focus paragraph) | +| `formatting` | `editor_formatting.rs:106` | ~~Multi-block-selection formatting~~ **fixed 2026-07-05** (plan 4b.2: per-paragraph ranges via `editor_format_range.rs`; cross-container still clamps) | | `undo-dirty` | `editor_state.rs:118` | ~~Saved-vs-undo-stack clean tracking~~ **fixed 2026-07-05** (plan 4b.3): `editing/saved_state.rs` clean checkpoint — undoing back to the save point clears dirty | | `nested-nav` | `navigation.rs:138,174` | ~~Sibling path inside cell/note body~~ **fixed 2026-07-05** (plan 4b.4): paginated navigation is path-aware; Left/Right cross cell/note siblings and clamp at the container edge | | `tabs` | `shell.rs:148`, `home.rs:89` | Tab-driven (vs router) navigation; blank-doc | diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 99d02661..1d90ebb8 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -117,7 +117,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | Task | Topic | Detail | Effort | |---|---|---|---| | 4b.1 | `3b-3` | ✅ **Done 2026-07-05** — (1) Left/Right cross page boundaries (the prev/next-entry searches walk the whole layout; entering a table from above/below lands in its first/last cell). (2) New `editing/page_locate.rs`: `recompute_page_index` re-derives a position's page from the layout, picking the page whose content band shows the byte's line for page-spanning paragraphs; wired into every mutation caret placement (`set_collapsed_cursor` — split, merge, typing, selection removal) and after every navigation move, replacing the stale-page TODOs. 4 page-locate tests (incl. a split-fragment band test) + 4 cross-page nav tests; `navigation.rs` split (`navigation_find.rs`) dropped it from the ceiling baseline (34 → 33). **Remaining `3b-3` tail:** the double-Enter list-exit heuristic (`clear_para_props`). | M | -| 4b.2 | `formatting` | Multi-block-selection formatting (today clamped to the focus paragraph) — `editor_formatting.rs:106`. | M | +| 4b.2 | `formatting` | ✅ **Done 2026-07-05** — the six toggles (bold/italic/underline/strikethrough/super/subscript) apply across every paragraph of a same-container multi-block selection: new `editor_format_range.rs` `resolve_format_ranges` yields per-paragraph `(BlockPath, start, end)` ranges (first-paragraph tail, full middles, last-paragraph head; text-less blocks like tables inside the range are skipped, their cells untouched); the state at the selection start decides apply-vs-clear and the whole selection is made uniform (Word behaviour). Cross-container selections keep the clamp-to-focus rule. 8 tests incl. an end-to-end double-toggle. | M | | 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Remaining tail:** Spec 01's typed `SaveError` residual; disabling the ribbon Save button when clean (needs the dirty flag threaded into the ribbon). | M | | 4b.4 | `nested-nav` | ✅ **Done 2026-07-05** — paginated navigation is path-aware end-to-end: `find_para_data` matches `(block_index, path)` (index alone returned the first cell's entry for every table paragraph), the `get_text` closures take a `BlockPath` (grapheme moves inside cells previously read the root block's empty text), and Left/Right at a nested paragraph's edges cross to the sibling within the same cell / note body, clamping at the container's first/last block. Inline tests extracted to `navigation_tests.rs` (`#[path]` idiom, file ratcheted 593 → 367) + 6 nested-nav regression tests. Reflow navigation stays top-level-only (tables have no reflow editing data). | S | | 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | diff --git a/loki-text/src/routes/editor/editor_format_range.rs b/loki-text/src/routes/editor/editor_format_range.rs new file mode 100644 index 00000000..1fe4ec21 --- /dev/null +++ b/loki-text/src/routes/editor/editor_format_range.rs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Format-range resolution: maps the cursor/selection to the per-paragraph +//! byte ranges a character-formatting action applies to (plan 4b.2). +//! +//! Extracted from `editor_formatting.rs` to keep that file under the +//! 300-line ceiling. +//! +//! # Byte offset coordinate space +//! +//! All byte offsets are UTF-8 byte positions, matching `CursorState` and the +//! `mark_utf8` API used by `mark_text_at`. + +use loki_doc_model::{BlockPath, PathStep, get_block_text_at}; +use loro::LoroDoc; + +use crate::editing::cursor::{CursorState, DocumentPosition}; + +#[cfg(test)] +#[path = "editor_format_range_tests.rs"] +mod tests; + +/// Resolves the format ranges from cursor state, one `(BlockPath, byte_start, +/// byte_end)` per paragraph the action applies to, in document order. +/// +/// - Selection within one paragraph → that byte range. +/// - Selection spanning sibling paragraphs of one container (top-level +/// blocks, or blocks of the same table cell / note body) → the tail of the +/// first paragraph, every middle paragraph in full, and the head of the +/// last. Non-text blocks inside the range (e.g. a table between two +/// top-level paragraphs) contribute an empty range and are skipped, so +/// their nested content is left untouched. +/// - Selection crossing containers (body ↔ cell, cell ↔ cell) → clamped to +/// the focus paragraph, mirroring the model layer's cross-container +/// rejection rule. +/// - Point cursor → the word at the cursor. +/// +/// Empty ranges are never returned; an empty `Vec` means there is nothing to +/// format. +pub fn resolve_format_ranges( + loro: &LoroDoc, + cursor: &CursorState, +) -> Vec<(BlockPath, usize, usize)> { + let Some(focus) = cursor.focus.as_ref() else { + return Vec::new(); + }; + let path = focus.block_path(); + + if cursor.has_selection() { + let Some(anchor) = cursor.anchor.as_ref() else { + return Vec::new(); + }; + // Same paragraph requires the same index *and* the same nesting path + // (two cells of one table share the root index but differ by path). + if anchor.paragraph_index == focus.paragraph_index && anchor.path == focus.path { + let (start, end) = if anchor.byte_offset <= focus.byte_offset { + (anchor.byte_offset, focus.byte_offset) + } else { + (focus.byte_offset, anchor.byte_offset) + }; + if start < end { + return vec![(path, start, end)]; + } + return Vec::new(); + } + if same_container(anchor, focus) { + return sibling_ranges(loro, anchor, focus); + } + if focus.byte_offset > 0 { + // Cross-container: clamp to the focus paragraph as a best-effort. + return vec![(path, 0, focus.byte_offset)]; + } + return Vec::new(); + } + + // No selection — expand to the word at cursor. + let text = get_block_text_at(loro, &path); + let (word_start, word_end) = word_bounds_at(&text, focus.byte_offset); + if word_start < word_end { + vec![(path, word_start, word_end)] + } else { + Vec::new() + } +} + +/// Resolves a single format range — the first of [`resolve_format_ranges`]. +/// +/// Used by actions that need one contiguous range (e.g. hyperlink insertion); +/// for a multi-paragraph selection this is the selection's portion of its +/// first paragraph. +pub fn resolve_format_range( + loro: &LoroDoc, + cursor: &CursorState, +) -> Option<(BlockPath, usize, usize)> { + resolve_format_ranges(loro, cursor).into_iter().next() +} + +/// The leaf block index of a position within its container (the leaf step's +/// block index for nested positions, the paragraph index for top-level ones). +fn leaf_index(pos: &DocumentPosition) -> usize { + match pos.path.last() { + Some(PathStep::Cell { block, .. } | PathStep::Note { block, .. }) => *block, + None => pos.paragraph_index, + } +} + +/// Whether two positions address sibling blocks of one container: both +/// top-level, or nested with the same root, the same non-leaf steps, and the +/// same leaf cell / note (mirrors `loro_mutation::selection`). +fn same_container(a: &DocumentPosition, b: &DocumentPosition) -> bool { + if a.path.len() != b.path.len() { + return false; + } + let Some(n) = a.path.len().checked_sub(1) else { + return true; // both top-level + }; + if a.paragraph_index != b.paragraph_index || a.path[..n] != b.path[..n] { + return false; + } + match (a.path[n], b.path[n]) { + (PathStep::Cell { cell: c1, .. }, PathStep::Cell { cell: c2, .. }) => c1 == c2, + (PathStep::Note { note: n1, .. }, PathStep::Note { note: n2, .. }) => n1 == n2, + _ => false, + } +} + +/// Per-paragraph ranges for a selection spanning sibling blocks of one +/// container: `[start_byte..len]` of the first, `[0..len]` of every middle +/// block, `[0..end_byte]` of the last. Empty ranges (empty paragraphs, +/// text-less blocks like tables) are skipped. +fn sibling_ranges( + loro: &LoroDoc, + a: &DocumentPosition, + b: &DocumentPosition, +) -> Vec<(BlockPath, usize, usize)> { + let (start, end) = if (leaf_index(a), a.byte_offset) <= (leaf_index(b), b.byte_offset) { + (a, b) + } else { + (b, a) + }; + let (start_leaf, end_leaf) = (leaf_index(start), leaf_index(end)); + + let mut ranges = Vec::with_capacity(end_leaf - start_leaf + 1); + for leaf in start_leaf..=end_leaf { + let pos = start.sibling_block(leaf as isize - start_leaf as isize, 0); + let bp = pos.block_path(); + let text_len = get_block_text_at(loro, &bp).len(); + let s = if leaf == start_leaf { + start.byte_offset + } else { + 0 + }; + let e = if leaf == end_leaf { + end.byte_offset.min(text_len) + } else { + text_len + }; + if s < e { + ranges.push((bp, s, e)); + } + } + ranges +} + +/// Returns the word boundary around `byte_offset` in `text` as +/// `(word_start_byte, word_end_byte)`. +/// +/// A "word character" is alphanumeric or underscore. If the cursor is +/// on whitespace or punctuation, `word_start == word_end` (empty word). +fn word_bounds_at(text: &str, byte_offset: usize) -> (usize, usize) { + let clamped = byte_offset.min(text.len()); + let before = &text[..clamped]; + + // Walk backward to find the last non-word character, then word starts one + // character after it. + let word_start = match before + .char_indices() + .rev() + .find(|(_, c)| !c.is_alphanumeric() && *c != '_') + { + Some((i, c)) => i + c.len_utf8(), + None => 0, + }; + + // Walk forward from clamped to find the first non-word character. + let after = &text[clamped..]; + let word_end = clamped + + match after + .char_indices() + .find(|(_, c)| !c.is_alphanumeric() && *c != '_') + { + Some((i, _)) => i, + None => after.len(), + }; + + (word_start, word_end) +} diff --git a/loki-text/src/routes/editor/editor_format_range_tests.rs b/loki-text/src/routes/editor/editor_format_range_tests.rs new file mode 100644 index 00000000..ad88549b --- /dev/null +++ b/loki-text/src/routes/editor/editor_format_range_tests.rs @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::document_to_loro; +use loki_doc_model::{BlockPath, PathStep}; + +use super::resolve_format_ranges; +use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::routes::editor::editor_formatting::toggle_bold; + +fn para(s: &str) -> Block { + Block::Para(vec![Inline::Str(s.into())]) +} + +fn loro_with_paras(texts: &[&str]) -> loro::LoroDoc { + let mut doc = Document::new(); + doc.sections[0].blocks = texts.iter().map(|t| para(t)).collect(); + document_to_loro(&doc).unwrap() +} + +fn selection(anchor: DocumentPosition, focus: DocumentPosition) -> CursorState { + let mut cs = CursorState::new(); + cs.anchor = Some(anchor); + cs.focus = Some(focus); + cs +} + +#[test] +fn point_cursor_expands_to_the_word() { + let loro = loro_with_paras(&["hello world"]); + let mut cs = CursorState::new(); + cs.anchor = Some(DocumentPosition::top_level(0, 0, 7)); + cs.focus = cs.anchor.clone(); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!(ranges, vec![(BlockPath::block(0), 6, 11)]); +} + +#[test] +fn single_paragraph_selection_is_one_range() { + let loro = loro_with_paras(&["hello world"]); + let cs = selection( + DocumentPosition::top_level(0, 0, 8), + DocumentPosition::top_level(0, 0, 2), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!(ranges, vec![(BlockPath::block(0), 2, 8)]); +} + +#[test] +fn multi_paragraph_selection_covers_every_block() { + let loro = loro_with_paras(&["Hello world", "middle", "goodbye"]); + // From byte 6 of block 0 to byte 4 of block 2, endpoints reversed. + let cs = selection( + DocumentPosition::top_level(0, 2, 4), + DocumentPosition::top_level(0, 0, 6), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!( + ranges, + vec![ + (BlockPath::block(0), 6, 11), // tail of "Hello world" + (BlockPath::block(1), 0, 6), // all of "middle" + (BlockPath::block(2), 0, 4), // head of "goodbye" + ] + ); +} + +#[test] +fn selection_ending_at_offset_zero_skips_the_empty_last_range() { + let loro = loro_with_paras(&["first", "second"]); + let cs = selection( + DocumentPosition::top_level(0, 0, 2), + DocumentPosition::top_level(0, 1, 0), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!(ranges, vec![(BlockPath::block(0), 2, 5)]); +} + +#[test] +fn selection_within_one_table_cell_covers_its_blocks() { + use loki_doc_model::content::attr::NodeAttr; + use loki_doc_model::content::table::core::{ + Table, TableBody, TableCaption, TableFoot, TableHead, + }; + use loki_doc_model::content::table::row::{Cell, Row}; + + let cell = Cell::simple(vec![para("alpha"), para("beta")]); + let table = Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![cell])])], + foot: TableFoot::empty(), + }; + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("intro"), Block::Table(Box::new(table))]; + let loro = document_to_loro(&doc).unwrap(); + + let pos = |block: usize, byte: usize| DocumentPosition { + page_index: 0, + paragraph_index: 1, + byte_offset: byte, + path: vec![PathStep::Cell { cell: 0, block }], + }; + let cs = selection(pos(0, 2), pos(1, 3)); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!( + ranges, + vec![ + (BlockPath::in_cell(1, 0, 0), 2, 5), // tail of "alpha" + (BlockPath::in_cell(1, 0, 1), 0, 3), // head of "beta" + ] + ); +} + +#[test] +fn cross_container_selection_clamps_to_the_focus_paragraph() { + let loro = loro_with_paras(&["top level"]); + let cs = selection( + DocumentPosition { + page_index: 0, + paragraph_index: 0, + byte_offset: 1, + path: vec![PathStep::Cell { cell: 0, block: 0 }], + }, + DocumentPosition::top_level(0, 0, 3), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!(ranges, vec![(BlockPath::block(0), 0, 3)]); +} + +#[test] +fn a_table_between_selected_paragraphs_is_skipped() { + use loki_doc_model::content::attr::NodeAttr; + use loki_doc_model::content::table::core::{ + Table, TableBody, TableCaption, TableFoot, TableHead, + }; + use loki_doc_model::content::table::row::{Cell, Row}; + + let table = Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![Cell::simple( + vec![para("cell")], + )])])], + foot: TableFoot::empty(), + }; + let mut doc = Document::new(); + doc.sections[0].blocks = vec![para("before"), Block::Table(Box::new(table)), para("after")]; + let loro = document_to_loro(&doc).unwrap(); + + // Top-level selection across the table: block 0 tail + block 2 head; the + // table (no text container) contributes nothing and its cell is untouched. + let cs = selection( + DocumentPosition::top_level(0, 0, 3), + DocumentPosition::top_level(0, 2, 3), + ); + let ranges = resolve_format_ranges(&loro, &cs); + assert_eq!( + ranges, + vec![(BlockPath::block(0), 3, 6), (BlockPath::block(2), 0, 3)] + ); +} + +#[test] +fn toggle_bold_marks_every_paragraph_of_a_multi_block_selection() { + use loki_doc_model::loro_bridge::loro_to_document; + + let loro = loro_with_paras(&["Hello world", "middle", "goodbye"]); + let cs = selection( + DocumentPosition::top_level(0, 0, 6), + DocumentPosition::top_level(0, 2, 4), + ); + assert!( + toggle_bold(&loro, &cs).unwrap(), + "first toggle applies bold" + ); + + let rebuilt = loro_to_document(&loro).unwrap(); + let bold_text = |block: usize| -> String { + let Block::Para(inlines) = &rebuilt.sections[0].blocks[block] else { + panic!("para"); + }; + inlines + .iter() + .filter_map(|i| match i { + Inline::StyledRun(r) + if r.direct_props + .as_ref() + .is_some_and(|p| p.bold == Some(true)) => + { + Some(r.content.iter().filter_map(|c| match c { + Inline::Str(s) => Some(s.as_str()), + _ => None, + })) + } + _ => None, + }) + .flatten() + .collect() + }; + assert_eq!(bold_text(0), "world"); + assert_eq!(bold_text(1), "middle"); + assert_eq!(bold_text(2), "good"); + + // Second toggle (state read at the selection start) removes it everywhere. + assert!( + !toggle_bold(&loro, &cs).unwrap(), + "second toggle clears bold" + ); + let rebuilt = loro_to_document(&loro).unwrap(); + let all_unbold = (0..3).all(|b| { + let Block::Para(inlines) = &rebuilt.sections[0].blocks[b] else { + panic!("para"); + }; + inlines.iter().all(|i| { + !matches!(i, Inline::StyledRun(r) + if r.direct_props.as_ref().is_some_and(|p| p.bold == Some(true))) + }) + }); + assert!(all_unbold, "bold cleared across the whole selection"); +} diff --git a/loki-text/src/routes/editor/editor_formatting.rs b/loki-text/src/routes/editor/editor_formatting.rs index 7c05c51e..0b25a27c 100644 --- a/loki-text/src/routes/editor/editor_formatting.rs +++ b/loki-text/src/routes/editor/editor_formatting.rs @@ -9,29 +9,30 @@ //! //! # Toggle semantics //! -//! Each `toggle_*` function reads the mark at the start of the resolved -//! format range. If the mark is already active, it is cleared (`LoroValue::Null`); -//! otherwise it is applied. This matches Word/LibreOffice toggle behaviour. +//! Each `toggle_*` function reads the mark at the start of the **first** +//! resolved range (the document-order start of the selection). If the mark +//! is already active there, it is cleared (`LoroValue::Null`) across every +//! range; otherwise it is applied across every range. This matches +//! Word/LibreOffice toggle behaviour, extended to multi-paragraph selections +//! (plan 4b.2): the state at the selection start decides, and the whole +//! selection is made uniform. //! //! # Range resolution //! -//! [`resolve_format_range`] maps `CursorState` to `(block_index, byte_start, byte_end)`. -//! With an active selection in a single block, the selection is used directly. -//! With a point cursor (no selection), the word at the cursor is expanded. -//! Cross-block selections are clamped to the focus block — a future pass can -//! extend this. -//! -//! # Byte offset coordinate space -//! -//! All byte offsets are UTF-8 byte positions, matching `CursorState` and the -//! `mark_utf8` API used by `mark_text`. +//! [`resolve_format_ranges`](super::editor_format_range::resolve_format_ranges) +//! maps `CursorState` to one `(BlockPath, byte_start, byte_end)` per +//! paragraph: the selection's ranges for single- and multi-paragraph +//! selections within one container, the word at the cursor for a point +//! cursor, and a clamp to the focus paragraph for cross-container +//! selections. See `editor_format_range.rs`. use loki_doc_model::loro_schema::{ MARK_BOLD, MARK_ITALIC, MARK_STRIKETHROUGH, MARK_UNDERLINE, MARK_VERTICAL_ALIGN, }; -use loki_doc_model::{BlockPath, MutationError, get_block_text_at, get_mark_at_path, mark_text_at}; +use loki_doc_model::{BlockPath, MutationError, get_mark_at_path, mark_text_at}; use loro::{LoroDoc, LoroValue}; +use super::editor_format_range::resolve_format_ranges; use crate::editing::cursor::CursorState; /// Whether the mark was applied (`true`) or removed (`false`). @@ -91,69 +92,40 @@ pub fn toggle_subscript( toggle_vertical_align(loro, cursor, "Subscript") } -// ── Range resolution ────────────────────────────────────────────────────────── +// ── Private helpers ─────────────────────────────────────────────────────────── -/// Resolves the format range from cursor state: `(BlockPath, byte_start, byte_end)`. -/// -/// The [`BlockPath`] addresses the focus paragraph — top-level or nested inside -/// a table cell / note body — so formatting applies to the right container. -/// With a selection within a single paragraph, the selection range is returned; -/// with a point cursor, the word at the cursor is expanded; a cross-paragraph -/// selection is clamped to the focus paragraph. -/// -/// Returns `None` when there is no valid cursor position. -/// -/// // TODO(formatting): extend to multi-block selections. -pub fn resolve_format_range( +/// Applies `new_value` for `mark_key` across every resolved range. +fn mark_ranges( loro: &LoroDoc, - cursor: &CursorState, -) -> Option<(BlockPath, usize, usize)> { - let focus = cursor.focus.as_ref()?; - let path = focus.block_path(); - - if cursor.has_selection() { - let anchor = cursor.anchor.as_ref()?; - // Same paragraph requires the same index *and* the same nesting path - // (two cells of one table share the root index but differ by path). - if anchor.paragraph_index == focus.paragraph_index && anchor.path == focus.path { - let (start, end) = if anchor.byte_offset <= focus.byte_offset { - (anchor.byte_offset, focus.byte_offset) - } else { - (focus.byte_offset, anchor.byte_offset) - }; - if start < end { - return Some((path, start, end)); - } - } else if focus.byte_offset > 0 { - // Cross-paragraph: clamp to the focus paragraph as a best-effort. - return Some((path, 0, focus.byte_offset)); - } - } - - // No selection — expand to the word at cursor. - let text = get_block_text_at(loro, &path); - let (word_start, word_end) = word_bounds_at(&text, focus.byte_offset); - if word_start < word_end { - Some((path, word_start, word_end)) - } else { - None + ranges: &[(BlockPath, usize, usize)], + mark_key: &str, + new_value: &LoroValue, +) -> Result<(), MutationError> { + for (path, byte_start, byte_end) in ranges { + mark_text_at( + loro, + path, + *byte_start, + *byte_end, + mark_key, + new_value.clone(), + )?; } + Ok(()) } -// ── Private helpers ─────────────────────────────────────────────────────────── - fn toggle_string_mark( loro: &LoroDoc, cursor: &CursorState, mark_key: &str, enable_value: &str, ) -> Result { - let (path, byte_start, byte_end) = match resolve_format_range(loro, cursor) { - Some(r) => r, - None => return Ok(false), + let ranges = resolve_format_ranges(loro, cursor); + let Some((first_path, first_start, _)) = ranges.first() else { + return Ok(false); }; let active = matches!( - get_mark_at_path(loro, &path, byte_start, mark_key)?, + get_mark_at_path(loro, first_path, *first_start, mark_key)?, Some(LoroValue::String(_)) ); let new_value = if active { @@ -161,7 +133,7 @@ fn toggle_string_mark( } else { LoroValue::from(enable_value.to_string()) }; - mark_text_at(loro, &path, byte_start, byte_end, mark_key, new_value)?; + mark_ranges(loro, &ranges, mark_key, &new_value)?; Ok(!active) } @@ -170,13 +142,13 @@ fn toggle_bool_mark( cursor: &CursorState, mark_key: &str, ) -> Result { - let (path, byte_start, byte_end) = match resolve_format_range(loro, cursor) { - Some(r) => r, - None => return Ok(false), + let ranges = resolve_format_ranges(loro, cursor); + let Some((first_path, first_start, _)) = ranges.first() else { + return Ok(false); }; let active = matches!( - get_mark_at_path(loro, &path, byte_start, mark_key)?, + get_mark_at_path(loro, first_path, *first_start, mark_key)?, Some(LoroValue::Bool(true)) ); @@ -185,7 +157,7 @@ fn toggle_bool_mark( } else { LoroValue::Bool(true) }; - mark_text_at(loro, &path, byte_start, byte_end, mark_key, new_value)?; + mark_ranges(loro, &ranges, mark_key, &new_value)?; Ok(!active) } @@ -194,13 +166,13 @@ fn toggle_vertical_align( cursor: &CursorState, target_str: &str, ) -> Result { - let (path, byte_start, byte_end) = match resolve_format_range(loro, cursor) { - Some(r) => r, - None => return Ok(false), + let ranges = resolve_format_ranges(loro, cursor); + let Some((first_path, first_start, _)) = ranges.first() else { + return Ok(false); }; let already_active = matches!( - get_mark_at_path(loro, &path, byte_start, MARK_VERTICAL_ALIGN)?, + get_mark_at_path(loro, first_path, *first_start, MARK_VERTICAL_ALIGN)?, Some(LoroValue::String(ref s)) if s.as_str() == target_str ); @@ -211,47 +183,6 @@ fn toggle_vertical_align( } else { LoroValue::from(target_str.to_string()) }; - mark_text_at( - loro, - &path, - byte_start, - byte_end, - MARK_VERTICAL_ALIGN, - new_value, - )?; + mark_ranges(loro, &ranges, MARK_VERTICAL_ALIGN, &new_value)?; Ok(!already_active) } - -/// Returns the word boundary around `byte_offset` in `text` as -/// `(word_start_byte, word_end_byte)`. -/// -/// A "word character" is alphanumeric or underscore. If the cursor is -/// on whitespace or punctuation, `word_start == word_end` (empty word). -fn word_bounds_at(text: &str, byte_offset: usize) -> (usize, usize) { - let clamped = byte_offset.min(text.len()); - let before = &text[..clamped]; - - // Walk backward to find the last non-word character, then word starts one - // character after it. - let word_start = match before - .char_indices() - .rev() - .find(|(_, c)| !c.is_alphanumeric() && *c != '_') - { - Some((i, c)) => i + c.len_utf8(), - None => 0, - }; - - // Walk forward from clamped to find the first non-word character. - let after = &text[clamped..]; - let word_end = clamped - + match after - .char_indices() - .find(|(_, c)| !c.is_alphanumeric() && *c != '_') - { - Some((i, _)) => i, - None => after.len(), - }; - - (word_start, word_end) -} diff --git a/loki-text/src/routes/editor/editor_insert.rs b/loki-text/src/routes/editor/editor_insert.rs index a71601b6..cf860423 100644 --- a/loki-text/src/routes/editor/editor_insert.rs +++ b/loki-text/src/routes/editor/editor_insert.rs @@ -31,7 +31,7 @@ use loki_doc_model::{ }; use loro::{LoroDoc, LoroValue}; -use super::editor_formatting::resolve_format_range; +use super::editor_format_range::resolve_format_range; use crate::editing::cursor::CursorState; /// EMU per CSS pixel at 96 DPI (1 inch = 914 400 EMU = 96 px). Inserted images diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index b364bb9d..ef279754 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -14,6 +14,7 @@ mod editor_compact; mod editor_docked_panels; mod editor_error_view; mod editor_font_warning; +mod editor_format_range; mod editor_formatting; mod editor_inner; mod editor_insert; From ceab960782e02233e0e4fe095be28bfc86ee4a64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 18:46:47 +0000 Subject: [PATCH 22/27] Status-bar touch targets: >= 44px-wide full-height hit areas (4c.2) Phase 4c.2 (TODO(a11y)): the status bar's interactive controls (notice chip, view-mode toggle, zoom badge) had touch targets the size of their small visual chips. Each control is now a transparent button at least TOUCH_MIN (44 px) wide filling the bar's full height, with the visual chip as a nested span (hover styling unchanged). The hit-area and chip styles are shared helpers. Honest constraint, documented on AtStatusBar: STATUS_BAR_HEIGHT is 24 px, so the vertical target meets WCAG 2.5.8 AA's 24 px minimum but not the suite's 44 px convention - that needs a taller bar on touch platforms, a design decision recorded as the 4c.2 tail in the plan rather than solved here with unverifiable overflow hit-testing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- appthere-ui/src/components/status_bar.rs | 116 +++++++++++++--------- docs/deferred-features-plan-2026-07-04.md | 2 +- 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/appthere-ui/src/components/status_bar.rs b/appthere-ui/src/components/status_bar.rs index 2039f82b..41e85bba 100644 --- a/appthere-ui/src/components/status_bar.rs +++ b/appthere-ui/src/components/status_bar.rs @@ -13,7 +13,7 @@ use crate::tokens::colors::{ COLOR_TEXT_ACCENT, COLOR_TEXT_ON_CHROME_SECONDARY, }; use crate::tokens::layout::STATUS_BAR_HEIGHT; -use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2, SPACE_4}; +use crate::tokens::spacing::{RADIUS_SM, SPACE_1, SPACE_2, SPACE_4, TOUCH_MIN}; use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_XS, FONT_WEIGHT_MEDIUM}; // ── AtStatusBar ─────────────────────────────────────────────────────────────── @@ -23,10 +23,14 @@ use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_XS, FONT_WEIGHT_MEDIUM /// Displays document statistics on the left and navigation controls (zoom, /// language, collaborator count) on the right. /// -/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** -/// -/// The zoom badge is smaller than `TOUCH_MIN` in its visual footprint. -/// TODO(a11y): Expand the invisible touch target to `TOUCH_MIN` using padding. +/// **Touch targets (WCAG 2.5.8):** every interactive control (notice chip, +/// view-mode toggle, zoom badge) is at least [`TOUCH_MIN`] (44 px) wide and +/// fills the bar's full [`STATUS_BAR_HEIGHT`] (24 px) — the WCAG 2.5.8 AA +/// 24 px minimum. The bar itself caps the vertical target below the suite's +/// 44 px convention; meeting it fully needs a taller bar on touch platforms +/// (a design decision tracked in the deferred-features plan, 4c.2 tail). +/// The visual chip is a smaller nested `span`; the transparent button around +/// it is the hit area. #[component] pub fn AtStatusBar(props: AtStatusBarProps) -> Element { let mut zoom_hovered = use_signal(|| false); @@ -101,26 +105,33 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { // Optional status notice chip (e.g. recover a dismissed warning). // Border + background only — no box-shadow (Blitz constraint). + // Hit area: full bar height × ≥ TOUCH_MIN wide (see component doc). if show_notice { button { "aria-label": props.notice_aria_label.clone(), style: format!( - "background: {bg}; border: 1px solid {border}; border-radius: {r}px; \ - color: {fg}; font-size: {size}px; font-weight: {weight}; \ - cursor: pointer; padding: {pv}px {ph}px; flex-shrink: 0;", - bg = notice_bg, - border = COLOR_CONTEXTUAL_TAB, - r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - size = FONT_SIZE_XS, - weight = FONT_WEIGHT_MEDIUM, - pv = SPACE_1, - ph = SPACE_2, + "{hit} background: transparent; border: none; cursor: pointer;", + hit = hit_area_style(), ), onmouseenter: move |_| { notice_hovered.set(true); }, onmouseleave: move |_| { notice_hovered.set(false); }, onclick: move |_| { props.on_notice_click.call(()); }, - "⚠ {props.notice_label}" + span { + style: format!( + "background: {bg}; border: 1px solid {border}; border-radius: {r}px; \ + color: {fg}; font-size: {size}px; font-weight: {weight}; \ + padding: {pv}px {ph}px;", + bg = notice_bg, + border = COLOR_CONTEXTUAL_TAB, + r = RADIUS_SM, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + size = FONT_SIZE_XS, + weight = FONT_WEIGHT_MEDIUM, + pv = SPACE_1, + ph = SPACE_2, + ), + "⚠ {props.notice_label}" + } } } @@ -143,51 +154,39 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { // View-mode toggle (paginated ⇆ reflowed). Hidden unless a label is // supplied, so apps that do not offer the toggle are unaffected. - // **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** - // TODO(a11y): expand the invisible touch target to TOUCH_MIN. + // Hit area: full bar height × ≥ TOUCH_MIN wide (see component doc). if show_view_toggle { button { "aria-label": props.view_mode_aria_label.clone(), style: format!( - "background: {bg}; border: none; border-radius: {r}px; \ - color: {fg}; font-size: {size}px; font-weight: {weight}; \ - cursor: pointer; padding: {pv}px {ph}px; flex-shrink: 0;", - bg = view_bg, - r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - size = FONT_SIZE_XS, - weight = FONT_WEIGHT_MEDIUM, - pv = SPACE_1, - ph = SPACE_2, + "{hit} background: transparent; border: none; cursor: pointer;", + hit = hit_area_style(), ), onmouseenter: move |_| { view_hovered.set(true); }, onmouseleave: move |_| { view_hovered.set(false); }, onclick: move |_| { props.on_view_mode_click.call(()); }, - "{props.view_mode_label}" + span { + style: chip_style(view_bg), + "{props.view_mode_label}" + } } } - // Zoom badge (clickable button) - // The zoom badge is smaller than TOUCH_MIN in its visual footprint. - // TODO(a11y): Expand the invisible touch target to TOUCH_MIN using padding. + // Zoom badge (clickable button). + // Hit area: full bar height × ≥ TOUCH_MIN wide (see component doc). button { "aria-label": props.zoom_aria_label, style: format!( - "background: {bg}; border: none; border-radius: {r}px; \ - color: {fg}; font-size: {size}px; font-weight: {weight}; \ - cursor: pointer; padding: {pv}px {ph}px; flex-shrink: 0;", - bg = zoom_bg, - r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - size = FONT_SIZE_XS, - weight = FONT_WEIGHT_MEDIUM, - pv = SPACE_1, - ph = SPACE_2, + "{hit} background: transparent; border: none; cursor: pointer;", + hit = hit_area_style(), ), onmouseenter: move |_| { zoom_hovered.set(true); }, onmouseleave: move |_| { zoom_hovered.set(false); }, onclick: move |_| { props.on_zoom_click.call(()); }, - "{props.zoom_percent}%" + span { + style: chip_style(zoom_bg), + "{props.zoom_percent}%" + } } // Collaborator badge (hidden when count is 0) @@ -205,6 +204,30 @@ pub fn AtStatusBar(props: AtStatusBarProps) -> Element { } } +/// Shared hit-area style for the status bar's interactive controls: at least +/// [`TOUCH_MIN`] wide and the bar's full height, centring the visual chip. +fn hit_area_style() -> String { + format!( + "min-width: {w}px; height: 100%; box-sizing: border-box; padding: 0; \ + display: flex; align-items: center; justify-content: center; flex-shrink: 0;", + w = TOUCH_MIN, + ) +} + +/// Visual chip style for the view-mode / zoom badges (`bg` swaps on hover). +fn chip_style(bg: &str) -> String { + format!( + "background: {bg}; border-radius: {r}px; color: {fg}; \ + font-size: {size}px; font-weight: {weight}; padding: {pv}px {ph}px;", + r = RADIUS_SM, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + size = FONT_SIZE_XS, + weight = FONT_WEIGHT_MEDIUM, + pv = SPACE_1, + ph = SPACE_2, + ) +} + // ── Props ───────────────────────────────────────────────────────────────────── /// Props for [`AtStatusBar`]. @@ -255,9 +278,8 @@ pub struct AtStatusBarProps { /// default) hides it, so apps that do not use it are unaffected. Generic by /// design — not font-specific. /// - /// **Min interactive size: 44×44 logical px (WCAG 2.5.8).** TODO(a11y): - /// expand the invisible touch target to `TOUCH_MIN` (shared with the zoom / - /// view-mode badges, which carry the same status-bar-height constraint). + /// Touch target: ≥ `TOUCH_MIN` wide × full bar height (see the component + /// doc for the shared status-bar-height constraint). #[props(default)] pub notice_label: String, diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 1d90ebb8..6684c5a0 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -129,7 +129,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | Task | Topics | Detail | Effort | |---|---|---|---| | 4c.1 | `ux` | ✅ **Done 2026-07-05** — the recents Delete menu action now only *requests* deletion; `AtConfirmDialog` performs it on confirm (all three apps' Home; `home.rs` split `home_util.rs` out to stay under the ceiling). | S | -| 4c.2 | `a11y` | Expand invisible status-bar touch targets to `TOUCH_MIN` (WCAG 2.5.8 is a stated project convention). | S | +| 4c.2 | `a11y` | ✅ **Done 2026-07-05 (bounded by bar height)** — the status bar's three interactive controls (notice chip, view-mode toggle, zoom badge) are now transparent hit areas ≥ `TOUCH_MIN` (44 px) wide × the bar's full height, with the visual chip nested inside. **Honest constraint:** the 24 px `STATUS_BAR_HEIGHT` caps the vertical target at WCAG 2.5.8 AA's 24 px minimum, below the suite's 44 px convention — meeting it fully requires a taller bar on touch platforms (design decision, deferred; documented on `AtStatusBar`). | S | | 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | | 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | | 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | Extract recent-file rows into child components (hooks-in-loops); wire zoom controls (all 3 apps) + spreadsheet ribbon tab-select/collapse; adopt `use_breakpoint()` in `AtHomeTab`; stable list keys + `active_slide_idx` fix-up on slide delete; word count in the status bar. | M | From 5c5aea1a47afe8a78013d3a51f74719973093dd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 19:09:40 +0000 Subject: [PATCH 23/27] Zoom controls, live word count, stable slide keys (4c.5 batch, part 1) Three items from the plan's 4c.5 batch (audit F6d/F7b/F7c): Zoom (F6d, 2 of 3 apps): - The status-bar zoom badge now cycles 50/75/100/125/150/200% (shared appthere_ui::next_zoom). - loki-text: real GPU zoom. DocPageSource carries a zoom factor; DocumentView scales the page tiles' CSS size and LokiPageSource scales the paint transform in step, so pages render sharp at every zoom; PageTile divides zoom back out of tile-local hit-test coordinates. The layout (in points) is untouched and reflow mode deliberately ignores zoom (its "zoom" is the layout width). - loki-presentation: the slide canvas scales its box, padding, and font sizes by the factor. - loki-spreadsheet: deferred with TODO(zoom) - grid zoom needs zoom-aware col_px plus the column-resize px<->pt math in one pass. Word count (F7c, loki-text): - editing/word_count.rs: allocation-free streaming counter over the document's display text; adjacent inline runs continue a word (styling inside a word doesn't split it), block boundaries and break inlines end one. Word-matching semantics: table cells and captions counted, footnote bodies and generated content excluded. Memoised per mutation into the existing plural editor-word-count key. 8 tests. Stable slide list state (F7b, loki-presentation): - Thumbnails key on the stable SlideId and bullet rows on shape id + paragraph (index keys made Dioxus reuse the wrong editable inputs on insert/delete). - Deleting the active slide now clamps active_idx explicitly instead of relying on render-time clamping. Ceiling ratchet: document_view.rs split (view_types.rs) resolves its baseline entry, and the reflow hit-test helpers moved to doc_page_source_reflow.rs so the zoom state didn't push doc_page_source.rs over; next_zoom lives in components/zoom.rs for the same reason. Baseline 34 -> 32 entries, nothing new baselined. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- appthere-ui/src/components/mod.rs | 2 + appthere-ui/src/components/zoom.rs | 19 +++ appthere-ui/src/lib.rs | 8 +- docs/deferred-features-audit-2026-07-04.md | 4 +- docs/deferred-features-plan-2026-07-04.md | 2 +- .../src/routes/editor/editor_canvas.rs | 54 ++++-- .../src/routes/editor/editor_inner.rs | 20 ++- .../src/routes/editor/slide_view.rs | 4 + loki-renderer/src/doc_page_source.rs | 55 +++--- loki-renderer/src/doc_page_source_reflow.rs | 42 +++++ loki-renderer/src/document_view.rs | 144 +++------------- loki-renderer/src/lib.rs | 2 + loki-renderer/src/page_paint_source.rs | 2 +- loki-renderer/src/page_tile.rs | 12 +- loki-renderer/src/view_types.rs | 135 +++++++++++++++ .../src/routes/editor/editor_inner.rs | 4 +- loki-text/src/editing/mod.rs | 1 + loki-text/src/editing/word_count.rs | 159 ++++++++++++++++++ loki-text/src/editing/word_count_tests.rs | 116 +++++++++++++ loki-text/src/routes/editor/editor_canvas.rs | 17 +- loki-text/src/routes/editor/editor_inner.rs | 74 +++----- .../routes/editor/editor_save_callbacks.rs | 72 ++++++++ loki-text/src/routes/editor/editor_state.rs | 4 + scripts/file-ceiling-baseline.txt | 5 +- 24 files changed, 706 insertions(+), 251 deletions(-) create mode 100644 appthere-ui/src/components/zoom.rs create mode 100644 loki-renderer/src/doc_page_source_reflow.rs create mode 100644 loki-renderer/src/view_types.rs create mode 100644 loki-text/src/editing/word_count.rs create mode 100644 loki-text/src/editing/word_count_tests.rs diff --git a/appthere-ui/src/components/mod.rs b/appthere-ui/src/components/mod.rs index 2ffc7661..6be3368e 100644 --- a/appthere-ui/src/components/mod.rs +++ b/appthere-ui/src/components/mod.rs @@ -15,6 +15,7 @@ pub mod ribbon; pub mod status_bar; pub mod tab_bar; pub mod title_bar; +pub mod zoom; pub use confirm_dialog::{AtConfirmDialog, AtConfirmDialogProps}; pub use document_tab::{AtDocumentTab, AtDocumentTabProps}; @@ -25,3 +26,4 @@ pub use ribbon::{AtRibbon, AtRibbonGroup, AtRibbonGroupProps, RibbonTabDesc, Rib pub use status_bar::{AtStatusBar, AtStatusBarProps}; pub use tab_bar::{AtDocumentTabData, AtTabBar, AtTabBarProps}; pub use title_bar::{AtTitleBar, AtTitleBarProps}; +pub use zoom::next_zoom; diff --git a/appthere-ui/src/components/zoom.rs b/appthere-ui/src/components/zoom.rs new file mode 100644 index 00000000..d7fb8b63 --- /dev/null +++ b/appthere-ui/src/components/zoom.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Zoom stepping shared by the suite's status-bar zoom badges. + +/// The zoom step after `current`, for the status bar's zoom badge — cycles +/// 50 → 75 → 100 → 125 → 150 → 200 → back to 50 (unknown values snap to 100). +#[must_use] +pub fn next_zoom(current: u32) -> u32 { + match current { + 50 => 75, + 75 => 100, + 100 => 125, + 125 => 150, + 150 => 200, + 200 => 50, + _ => 100, + } +} diff --git a/appthere-ui/src/lib.rs b/appthere-ui/src/lib.rs index 807468cf..b9a8da59 100644 --- a/appthere-ui/src/lib.rs +++ b/appthere-ui/src/lib.rs @@ -42,10 +42,10 @@ pub use components::ribbon::{ AtRibbon, AtRibbonGroup, AtRibbonIconButton, AtRibbonSelect, RibbonTabDesc, RibbonTabIndex, }; pub use components::{ - AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, AtDocumentTabProps, - AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, AtStatusBarProps, - AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, PanelPosture, Platform, - RecentDocument, + next_zoom, AtConfirmDialog, AtConfirmDialogProps, AtDocumentTab, AtDocumentTabData, + AtDocumentTabProps, AtHomeTab, AtHomeTabProps, AtPanelHost, AtPanelHostProps, AtStatusBar, + AtStatusBarProps, AtTabBar, AtTabBarProps, AtTitleBar, AtTitleBarProps, BuiltinTemplate, + PanelPosture, Platform, RecentDocument, }; pub use responsive::{ page_fits, required_page_width, resolve_page_fit, use_breakpoint, use_provide_responsive, diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index c3b77330..e37a033c 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -197,8 +197,8 @@ per-sub-item verdicts against HEAD `20b05a6`: | F3 | Edits lost on tab switch; dirty flag never set | **LARGELY RESOLVED** | Per-tab retention via `loki-text/src/sessions.rs` `DocSession` (stash/restore in `editor_path_sync.rs:42-154`); dirty tracks a generation baseline (`editor_inner.rs:465-476`), cleared on save. **Residual (F3c) resolved 2026-07-05** (plan 4b.6): closing a dirty tab raises an `AtConfirmDialog` confirmation in all three apps' shells. | | F4 | Untitled documents cannot be saved; no Ctrl+S | **RESOLVED** | Save As via `pick_file_to_save` (`editor_inner.rs:484-535`); Ctrl/Cmd+S bound (`editor_keydown.rs:60-67`) routing untitled → Save As. | | F5 | Settle/retier pipeline wired to dead channels | **RESOLVED (by removal)** | The pipeline was deleted, not fixed: virtualization now bounds memory by mounting only viewport-window pages (`virtualize.rs` `visible_window`, `document_view.rs:290`). The 06-10 audit's §5 claim of "downsample by viewport distance" was wrong about the mechanism — corrected there. **Residual:** `DocumentViewProps::eq` still hardwired `false` (`document_view.rs:143-147`) — now a benign over-render, capped by `PageTile`'s own `PartialEq`. | -| F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom controls dead in all 3 apps; spreadsheet ribbon tab-select/collapse dead**. RESOLVED 2026-07-05: F6c-selection — typing replaces the active selection and Backspace/Delete remove it, including multi-block ranges (`loki_doc_model::delete_selection_at` composes `merge_block_at` + `delete_text_at` with pre-validation, so cross-container or table-spanning ranges are rejected untouched; wired via `editor_keydown_text.rs`; tested by `loro_selection_delete_tests.rs` + editor unit tests). STILL-OPEN: F6a hooks in conditionals/loops (`recent_files.rs:45,96,219`), F6c-clipboard (copy/cut/paste — partially gated on the unimplemented dioxus-native-dom clipboard converter), F6f synchronous save/load on UI thread. | -| F7 | Low grab-bag | **PARTIAL** | RESOLVED: F7d safe-area insets (RwLock + reactive version signal + resize sensor). PARTIAL: F7c — loki-text page number now live; word count still empty everywhere. STILL-OPEN: F7a `AtHomeTab` responsive layout (`viewport_width` fixed 375.0, never adopted `use_breakpoint()`), F7b index-based list keys + `active_slide_idx` not adjusted on delete-before-active, F7e debug leftovers in vendored patches, F7f `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`). | +| F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom wired 2026-07-05 in loki-text (GPU tile scale) + loki-presentation (slide scale); spreadsheet zoom (`TODO(zoom)`) and spreadsheet ribbon tab-select/collapse still dead**. RESOLVED 2026-07-05: F6c-selection — typing replaces the active selection and Backspace/Delete remove it, including multi-block ranges (`loki_doc_model::delete_selection_at` composes `merge_block_at` + `delete_text_at` with pre-validation, so cross-container or table-spanning ranges are rejected untouched; wired via `editor_keydown_text.rs`; tested by `loro_selection_delete_tests.rs` + editor unit tests). STILL-OPEN: F6a hooks in conditionals/loops (`recent_files.rs:45,96,219`), F6c-clipboard (copy/cut/paste — partially gated on the unimplemented dioxus-native-dom clipboard converter), F6f synchronous save/load on UI thread. | +| F7 | Low grab-bag | **PARTIAL** | RESOLVED: F7d safe-area insets (RwLock + reactive version signal + resize sensor). PARTIAL → word count now live in loki-text (2026-07-05); still empty in Calc/Slides (no meaningful doc-text count yet). RESOLVED 2026-07-05: F7b (stable slide/bullet list keys + explicit `active_idx` clamp on slide delete), F7c word count (live in loki-text's status bar; `editing/word_count.rs`). STILL-OPEN: F7a `AtHomeTab` responsive layout (`viewport_width` fixed 375.0, never adopted `use_breakpoint()`), F7e debug leftovers in vendored patches, F7f `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`). | **Two stale in-code comments found during verification were fixed in the same pass:** the `TODO(undo-dirty)` parenthetical ("Save not implemented" — Save now diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 6684c5a0..f4095b79 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -132,7 +132,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4c.2 | `a11y` | ✅ **Done 2026-07-05 (bounded by bar height)** — the status bar's three interactive controls (notice chip, view-mode toggle, zoom badge) are now transparent hit areas ≥ `TOUCH_MIN` (44 px) wide × the bar's full height, with the visual chip nested inside. **Honest constraint:** the 24 px `STATUS_BAR_HEIGHT` caps the vertical target at WCAG 2.5.8 AA's 24 px minimum, below the suite's 44 px convention — meeting it fully requires a taller bar on touch platforms (design decision, deferred; documented on `AtStatusBar`). | S | | 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | | 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | -| 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | Extract recent-file rows into child components (hooks-in-loops); wire zoom controls (all 3 apps) + spreadsheet ribbon tab-select/collapse; adopt `use_breakpoint()` in `AtHomeTab`; stable list keys + `active_slide_idx` fix-up on slide delete; word count in the status bar. | M | +| 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | **Partially done 2026-07-05:** ✅ F7b — slide thumbnails/bullets use stable keys (`SlideId`, shape+para) and slide deletion clamps `active_idx` explicitly. ✅ F7c — live word count in loki-text's status bar (`editing/word_count.rs`: streaming counter, Word-matching semantics — tables counted, notes excluded; 8 tests; plural `editor-word-count` key). ✅ F6d-zoom (2 of 3 apps) — the status-bar zoom badge cycles 50–200% (`appthere_ui::next_zoom`): loki-text scales the GPU page tiles + paint transform together (`DocPageSource::zoom` → `DocumentViewProps::zoom` → `PageTile` hit-test divide-out; reflow unaffected by design), loki-presentation scales the slide box + text; **spreadsheet zoom deferred** (`TODO(zoom)` — needs zoom-aware `col_px` + resize px↔pt in one pass). **Remaining:** F6a hooks-in-loops (`recent_files.rs`), F6d spreadsheet ribbon tab-select/collapse, F7a `AtHomeTab` `use_breakpoint()`. Ceiling wins: `document_view.rs` split (`view_types.rs`) resolved its baseline entry (34 → 32). | M | --- diff --git a/loki-presentation/src/routes/editor/editor_canvas.rs b/loki-presentation/src/routes/editor/editor_canvas.rs index 2678d8ad..6a47dd46 100644 --- a/loki-presentation/src/routes/editor/editor_canvas.rs +++ b/loki-presentation/src/routes/editor/editor_canvas.rs @@ -34,7 +34,7 @@ pub(super) fn SlideThumbnails( padding: 12px; gap: 12px;", for (i, v) in views.iter().enumerate() { div { - key: "{i}", + key: "{v.slide_id}", style: format!( "position: relative; width: 100%; height: 90px; background: {bg}; \ border: 2px solid {border}; border-radius: 6px; cursor: pointer; \ @@ -86,23 +86,34 @@ pub(super) fn SlideCanvas( view: SlideView, on_edit: EventHandler, on_add_bullet: EventHandler<()>, + /// Render zoom factor (1.0 = 100%): scales the slide box and its text + /// together (status-bar zoom badge; 4c.5). + zoom: f64, ) -> Element { + let z = |px: f64| px * zoom; rsx! { div { style: format!( - "width: 720px; height: 405px; background: {bg}; color: {fg}; \ + "width: {w}px; height: {h}px; background: {bg}; color: {fg}; \ border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); \ - padding: 40px; box-sizing: border-box; display: flex; \ - flex-direction: column; justify-content: flex-start; gap: 8px;", + padding: {pad}px; box-sizing: border-box; display: flex; \ + flex-direction: column; justify-content: flex-start; gap: {gap}px;", + w = z(720.0), + h = z(405.0), + pad = z(40.0), + gap = z(8.0), bg = view.bg_css, fg = view.fg_css, ), if let Some(t) = &view.title { input { - style: "font-size: 32px; font-weight: bold; background: transparent; \ - border: 1px dashed rgba(128,128,128,0.4); color: inherit; \ - width: 100%; outline: none; font-family: inherit; padding: 2px;", + style: format!( + "font-size: {fs}px; font-weight: bold; background: transparent; \ + border: 1px dashed rgba(128,128,128,0.4); color: inherit; \ + width: 100%; outline: none; font-family: inherit; padding: 2px;", + fs = z(32.0), + ), value: "{t.text}", placeholder: fl!("editor-placeholder-title"), oninput: { @@ -116,9 +127,12 @@ pub(super) fn SlideCanvas( if let Some(s) = &view.subtitle { input { - style: "font-size: 16px; font-style: italic; background: transparent; \ - border: 1px dashed rgba(128,128,128,0.4); color: inherit; \ - width: 100%; outline: none; font-family: inherit; padding: 2px; opacity: 0.85;", + style: format!( + "font-size: {fs}px; font-style: italic; background: transparent; \ + border: 1px dashed rgba(128,128,128,0.4); color: inherit; \ + width: 100%; outline: none; font-family: inherit; padding: 2px; opacity: 0.85;", + fs = z(16.0), + ), value: "{s.text}", placeholder: fl!("editor-placeholder-subtitle"), oninput: { @@ -132,14 +146,22 @@ pub(super) fn SlideCanvas( ul { style: "margin: 12px 0 0 0; padding-left: 20px; flex: 1; overflow-y: auto;", - for (i, line) in view.bullets.iter().enumerate() { + for line in view.bullets.iter() { li { - key: "{i}", - style: "margin: 6px 0; font-size: 16px; list-style-type: square;", + // Shape + paragraph is stable across bullet edits, so + // inserting a bullet re-uses the right inputs (F7b). + key: "{line.shape_id}-{line.para}", + style: format!( + "margin: 6px 0; font-size: {fs}px; list-style-type: square;", + fs = z(16.0), + ), input { - style: "background: transparent; border: 1px dashed rgba(128,128,128,0.4); \ - color: inherit; width: 92%; outline: none; font-size: 16px; \ - font-family: inherit; padding: 2px;", + style: format!( + "background: transparent; border: 1px dashed rgba(128,128,128,0.4); \ + color: inherit; width: 92%; outline: none; font-size: {fs}px; \ + font-family: inherit; padding: 2px;", + fs = z(16.0), + ), value: "{line.text}", oninput: { let id = line.shape_id.clone(); diff --git a/loki-presentation/src/routes/editor/editor_inner.rs b/loki-presentation/src/routes/editor/editor_inner.rs index 2befdc14..1692f5fc 100644 --- a/loki-presentation/src/routes/editor/editor_inner.rs +++ b/loki-presentation/src/routes/editor/editor_inner.rs @@ -38,6 +38,8 @@ pub(super) fn EditorInner(path: String) -> Element { let mut active_idx = use_signal(|| 0usize); let mut dirty = use_signal(|| false); let mut save_message = use_signal(|| Option::::None); + // Slide render zoom, in percent (status-bar badge; 4c.5). + let mut zoom_percent = use_signal(|| 100_u32); // Stashed sessions for inactive tabs — unsaved edits survive tab switches // (audit F1 residual / plan 4b.6). let doc_sessions = use_context::>(); @@ -171,7 +173,15 @@ pub(super) fn EditorInner(path: String) -> Element { button { style: toolbar_btn_style(), onclick: move |_| { - if let Some(p) = doc.write().as_mut() { edit::delete_slide(p, idx); } + let new_len = { + let mut d = doc.write(); + let Some(p) = d.as_mut() else { return }; + edit::delete_slide(p, idx); + p.slide_count() + }; + // Keep the selection on the same position, clamped to + // the shrunken deck (F7b). + active_idx.set(idx.min(new_len.saturating_sub(1))); dirty.set(true); }, {fl!("editor-action-delete-slide")} @@ -214,6 +224,7 @@ pub(super) fn EditorInner(path: String) -> Element { if let Some(v) = active { SlideCanvas { view: v, + zoom: zoom_percent() as f64 / 100.0, on_edit: move |msg: EditMsg| { if let Some(p) = doc.write().as_mut() { edit::set_shape_text(p, idx, &msg.shape_id, msg.para, &msg.text); @@ -243,11 +254,14 @@ pub(super) fn EditorInner(path: String) -> Element { ), word_count_label: String::new(), language_label: fl!("editor-language"), - zoom_percent: 100, + zoom_percent: zoom_percent(), collaborator_count: 0, collaborator_label: String::new(), zoom_aria_label: fl!("editor-zoom-aria"), - on_zoom_click: |_| {}, + on_zoom_click: move |_| { + let next = appthere_ui::next_zoom(*zoom_percent.peek()); + zoom_percent.set(next); + }, } } } diff --git a/loki-presentation/src/routes/editor/slide_view.rs b/loki-presentation/src/routes/editor/slide_view.rs index 9c737dcf..1673d9a2 100644 --- a/loki-presentation/src/routes/editor/slide_view.rs +++ b/loki-presentation/src/routes/editor/slide_view.rs @@ -36,6 +36,9 @@ pub(super) struct EditableLine { /// A flattened, edit-aware view of one slide. #[derive(Debug, Clone, PartialEq)] pub(super) struct SlideView { + /// The slide's stable identifier — the Dioxus list key for thumbnails, so + /// inserting/deleting slides re-uses the right DOM nodes (F7b). + pub slide_id: String, /// Title field, if the slide has a title placeholder. pub title: Option, /// Subtitle field, if present. @@ -76,6 +79,7 @@ fn slide_to_view(slide: &Slide) -> SlideView { }; SlideView { + slide_id: slide.id.as_str().to_string(), title, subtitle, bullets, diff --git a/loki-renderer/src/doc_page_source.rs b/loki-renderer/src/doc_page_source.rs index 8de3187d..a9d2a8d3 100644 --- a/loki-renderer/src/doc_page_source.rs +++ b/loki-renderer/src/doc_page_source.rs @@ -31,9 +31,7 @@ use loki_doc_model::document::Document; use loki_layout::{DocumentLayout, FontResources, LayoutMode, LayoutOptions, PaginatedLayout}; use loki_vello::FontDataCache; -use crate::render_layout::{ - MIN_REFLOW_CONTENT_PT, REFLOW_PADDING_PT, REFLOW_TILE_HEIGHT_PT, RenderLayout, RenderMode, -}; +use crate::render_layout::{MIN_REFLOW_CONTENT_PT, REFLOW_PADDING_PT, RenderLayout, RenderMode}; // ── A4 page size at 96 dpi ──────────────────────────────────────────────────── @@ -80,6 +78,11 @@ pub struct DocPageSource { /// (whose `texture_generation` initialises to 0) always renders on its /// first frame. generation: Arc, + /// Render zoom factor (1.0 = 100%). Paginated mode only: it scales the + /// tile CSS size and the paint transform together, leaving the layout — + /// which stays in points — untouched. Reflow keeps 1.0 (its "zoom" is the + /// layout width). See `DocumentView` / `LokiPageSource::render`. + zoom: Mutex, } impl DocPageSource { @@ -93,9 +96,23 @@ impl DocPageSource { layout_resources: Mutex::new(None), renderer: Mutex::new(None), generation: Arc::new(AtomicU64::new(1)), + zoom: Mutex::new(1.0), } } + /// Sets the paginated render zoom factor (clamped to a sane range). The + /// next paint picks it up; the tile resize that accompanies a zoom change + /// forces the repaint (texture-size mismatch), so no generation bump is + /// needed. + pub fn set_zoom(&self, zoom: f32) { + *self.zoom.lock().unwrap_or_else(|e| e.into_inner()) = zoom.clamp(0.25, 4.0); + } + + /// The current paginated render zoom factor. + pub fn zoom(&self) -> f32 { + *self.zoom.lock().unwrap_or_else(|e| e.into_inner()) + } + /// Returns the current document. pub fn document(&self) -> Arc { self.doc.lock().unwrap_or_else(|e| e.into_inner()).clone() @@ -106,38 +123,6 @@ impl DocPageSource { self.generation.load(Ordering::Acquire) } - /// Hit-test a tile-local click in the reflow layout, returning - /// `(block_index, byte_offset)`. - /// - /// `tile_index` is the band tile clicked; `tile_x_pt` / `tile_y_pt` are the - /// tile-local position in layout points. Returns `None` in paginated mode or - /// when there is no editing data at the point. - pub fn reflow_hit_test( - &self, - tile_index: usize, - tile_x_pt: f32, - tile_y_pt: f32, - ) -> Option<(usize, usize)> { - let guard = self.layout_for_generation(self.current_generation()); - let (_, layout) = guard.as_ref()?; - // Tile-local → canvas: undo the band's x inset and y offset. - let canvas_x = tile_x_pt - REFLOW_PADDING_PT; - let canvas_y = tile_y_pt + tile_index as f32 * REFLOW_TILE_HEIGHT_PT; - layout.reflow_hit_test(canvas_x, canvas_y) - } - - /// The reflow band (tile) index containing the caret for `(block_index, - /// byte_offset)`, or `None` in paginated mode / when not found. - /// - /// The view uses this as the caret's `page_index` so the correct tile is - /// invalidated (and repainted) as the caret moves between bands. - pub fn reflow_cursor_band(&self, block_index: usize, byte_offset: usize) -> Option { - let guard = self.layout_for_generation(self.current_generation()); - let (_, layout) = guard.as_ref()?; - let cr = layout.reflow_cursor_canvas(block_index, byte_offset)?; - Some((cr.y / REFLOW_TILE_HEIGHT_PT).floor().max(0.0) as usize) - } - /// Increments the generation counter. /// /// Call this after applying a document mutation so that [`LokiPageSource`] diff --git a/loki-renderer/src/doc_page_source_reflow.rs b/loki-renderer/src/doc_page_source_reflow.rs new file mode 100644 index 00000000..9b439bc3 --- /dev/null +++ b/loki-renderer/src/doc_page_source_reflow.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Reflow-mode hit-test helpers for [`DocPageSource`]. Split from +//! `doc_page_source.rs` to keep it under the file-size ceiling. + +use crate::doc_page_source::DocPageSource; +use crate::render_layout::{REFLOW_PADDING_PT, REFLOW_TILE_HEIGHT_PT}; + +impl DocPageSource { + /// Hit-test a tile-local click in the reflow layout, returning + /// `(block_index, byte_offset)`. + /// + /// `tile_index` is the band tile clicked; `tile_x_pt` / `tile_y_pt` are the + /// tile-local position in layout points. Returns `None` in paginated mode or + /// when there is no editing data at the point. + pub fn reflow_hit_test( + &self, + tile_index: usize, + tile_x_pt: f32, + tile_y_pt: f32, + ) -> Option<(usize, usize)> { + let guard = self.layout_for_generation(self.current_generation()); + let (_, layout) = guard.as_ref()?; + // Tile-local → canvas: undo the band's x inset and y offset. + let canvas_x = tile_x_pt - REFLOW_PADDING_PT; + let canvas_y = tile_y_pt + tile_index as f32 * REFLOW_TILE_HEIGHT_PT; + layout.reflow_hit_test(canvas_x, canvas_y) + } + + /// The reflow band (tile) index containing the caret for `(block_index, + /// byte_offset)`, or `None` in paginated mode / when not found. + /// + /// The view uses this as the caret's `page_index` so the correct tile is + /// invalidated (and repainted) as the caret moves between bands. + pub fn reflow_cursor_band(&self, block_index: usize, byte_offset: usize) -> Option { + let guard = self.layout_for_generation(self.current_generation()); + let (_, layout) = guard.as_ref()?; + let cr = layout.reflow_cursor_canvas(block_index, byte_offset)?; + Some((cr.y / REFLOW_TILE_HEIGHT_PT).floor().max(0.0) as usize) + } +} diff --git a/loki-renderer/src/document_view.rs b/loki-renderer/src/document_view.rs index 384704a0..80568f54 100644 --- a/loki-renderer/src/document_view.rs +++ b/loki-renderer/src/document_view.rs @@ -7,8 +7,6 @@ use std::sync::{Arc, Mutex}; #[cfg(any(not(target_os = "android"), android_gpu))] use dioxus::prelude::*; -use loki_doc_model::document::Document; -use loki_layout::PaginatedLayout; // PageTile (and the wgpu paint path under it) is enabled on: desktop, and // Android devices built with RUSTFLAGS='--cfg android_gpu' (Vulkan-capable @@ -25,126 +23,9 @@ use crate::renderer_state::RendererState; #[cfg(all(target_os = "android", not(android_gpu)))] use crate::reflow_view::ReflowDocView; -// ── ViewMode ────────────────────────────────────────────────────────────────── - -/// How the document is laid out in the canvas. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub enum ViewMode { - /// Fixed print layout — one fixed-size page tile per page (needs the GPU - /// paint path). This is the default on large viewports. - #[default] - Paginated, - /// Reflowable, web-page-style continuous layout that wraps to the viewport - /// width. The default on small viewports, and the only mode available on - /// the Android CPU path. - Reflow, -} - -// ── RendererCursorPos ───────────────────────────────────────────────────────── - -/// Minimal cursor position for GPU painting. No Loro dependency. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct RendererCursorPos { - pub page_index: usize, - pub paragraph_index: usize, - pub byte_offset: usize, -} - -/// Caret + optional range selection for GPU painting. `anchor == focus` (by -/// paragraph/byte) means a collapsed caret with no selection. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct RendererSelection { - pub focus: RendererCursorPos, - pub anchor: RendererCursorPos, -} - -impl RendererSelection { - /// True when there is no range selection (anchor and focus coincide). - pub fn is_collapsed(&self) -> bool { - self.anchor.paragraph_index == self.focus.paragraph_index - && self.anchor.byte_offset == self.focus.byte_offset - } -} - -/// A right-click on a page tile, carrying both the tile-local layout-point -/// coordinates (for an accurate hit test) and the window-relative client -/// coordinates (to anchor a floating menu at the cursor). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct TileContext { - /// Index of the page tile that was right-clicked. - pub page_index: usize, - /// X within the tile, in layout points (from `element_coordinates`). - pub x_pt: f32, - /// Y within the tile, in layout points. - pub y_pt: f32, - /// Window-relative X of the cursor, in CSS pixels. - pub client_x: f32, - /// Window-relative Y of the cursor, in CSS pixels. - pub client_y: f32, -} - -// ── DocumentViewProps ───────────────────────────────────────────────────────── - -/// Props for the DocumentView component. -#[derive(Props, Clone)] -pub struct DocumentViewProps { - pub doc: Arc, - /// Paginated layout already computed by the editor for `doc`, reused in - /// paginated mode so the renderer does not lay the document out a second - /// time (the single canonical layout). `None` falls back to computing it - /// on the render path (e.g. before the first editor layout, or on the - /// Android CPU reflow path). - pub paginated_layout: Option>, - pub viewport_height_px: f64, - /// Current vertical scroll offset of the editor's scroll container, in CSS - /// px. Drives tile virtualization: only pages within ~one screen of this - /// offset are GPU-rendered. The editor owns the scroll container (this - /// component is laid out inside it), so the scroll position must be passed - /// in — the renderer's own scroll signal is not updated by the real scroll. - pub viewport_top_px: f64, - /// The caret / selection focus position. - pub cursor_pos: Option, - /// The selection anchor. When it differs from `cursor_pos`, a range - /// selection is highlighted between them (reflow mode). - pub selection_anchor: Option, - /// Current layout mode. Ignored on the Android CPU path, which only supports - /// [`ViewMode::Reflow`]. - pub view_mode: ViewMode, - /// Available viewport width in CSS pixels for [`ViewMode::Reflow`]. - /// `<= 0` means "not yet measured" — the view falls back to paginated - /// rendering until a real width arrives. - pub reflow_width_px: f64, - /// Called with `(page_index, x_pt, y_pt)` in layout points when the user - /// clicks a page tile in **paginated** mode. The caller performs the hit test - /// and updates cursor state. - pub on_tile_click: EventHandler<(usize, f32, f32)>, - /// Called with `(block_index, byte_offset)` when the user clicks in - /// **reflow** mode. This component owns the reflow layout, so it hit-tests - /// the click itself and reports the resolved document position. - pub on_reflow_click: EventHandler<(usize, usize)>, - /// Called with `(block_index, byte_offset)` while drag-selecting in - /// **reflow** mode (mouse moved with a button held). The caller extends the - /// selection focus to this position. - pub on_reflow_drag: EventHandler<(usize, usize)>, - /// Called when a page tile is right-clicked (paginated mode), carrying the - /// accurate tile-local + client coordinates. Drives the spelling context menu. - pub on_tile_context: EventHandler, - /// Vertical gap between page tiles in paginated mode, in CSS px. Injected by - /// the app (from `appthere_ui::tokens::PAGE_GAP_PX`) rather than imported - /// here, so the render layer does not depend on the UI layer — Spec 01 audit - /// A-8. Zeroed automatically in reflow mode. - pub page_gap_px: f64, - /// Bottom padding below the last page, in CSS px (from - /// `appthere_ui::tokens::SPACE_6`). Injected for the same reason as - /// `page_gap_px`. - pub content_padding_bottom_px: f32, -} - -impl PartialEq for DocumentViewProps { - fn eq(&self, _other: &Self) -> bool { - false // Conservatively always re-render - } -} +pub use crate::view_types::{ + DocumentViewProps, RendererCursorPos, RendererSelection, TileContext, ViewMode, +}; // ── DocumentView ────────────────────────────────────────────────────────────── @@ -199,6 +80,14 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { RenderMode::Paginated }; renderer.source.set_render_mode(render_mode); + // Zoom applies to paginated tiles only; reflow "zoom" would be a + // layout-width change, so it stays at 1.0 there. + let zoom = if render_mode == RenderMode::Paginated { + props.zoom.max(0.25) + } else { + 1.0 + }; + renderer.source.set_zoom(zoom as f32); // Single canonical layout: in paginated mode reuse the layout the editor // already computed for this document instead of laying it out again. // Provided after set_render_mode so it is keyed to the current @@ -223,9 +112,13 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { let pages: Vec<(usize, f64, f64)> = if let Some((_, layout)) = layout_guard.as_ref() { (0..layout.page_count()) .filter_map(|i| { - layout - .page_size_pts(i) - .map(|(w, h)| (i, w as f64 * PTS_TO_CSS_PX, h as f64 * PTS_TO_CSS_PX)) + layout.page_size_pts(i).map(|(w, h)| { + ( + i, + w as f64 * PTS_TO_CSS_PX * zoom, + h as f64 * PTS_TO_CSS_PX * zoom, + ) + }) }) .collect() } else { @@ -331,6 +224,7 @@ pub fn DocumentView(props: DocumentViewProps) -> Element { page_index: idx, w, h, + zoom, shared_renderer: renderer.shared_renderer.clone(), cursor_holder: cursor_holder.clone(), selection, diff --git a/loki-renderer/src/lib.rs b/loki-renderer/src/lib.rs index 4c1cc0ff..e14d0510 100644 --- a/loki-renderer/src/lib.rs +++ b/loki-renderer/src/lib.rs @@ -13,12 +13,14 @@ #![forbid(unsafe_code)] pub mod doc_page_source; +mod doc_page_source_reflow; pub mod document_view; #[cfg(any(not(target_os = "android"), android_gpu))] pub mod page_paint_source; pub(crate) mod page_source_impl; #[cfg(any(not(target_os = "android"), android_gpu))] pub(crate) mod page_tile; +mod view_types; // The HTML-flow fallback view is only compiled on the Android CPU path; GPU // targets render reflow mode through the layout engine (RenderMode::Reflow). #[cfg(all(target_os = "android", not(android_gpu)))] diff --git a/loki-renderer/src/page_paint_source.rs b/loki-renderer/src/page_paint_source.rs index 5f6a56b1..40fc5196 100644 --- a/loki-renderer/src/page_paint_source.rs +++ b/loki-renderer/src/page_paint_source.rs @@ -198,7 +198,7 @@ impl CustomPaintSource for LokiPageSource { let view = texture.create_view(&TextureViewDescriptor::default()); // Step 6: build Vello scene for this page. - let render_scale = scale as f32 * (96.0 / 72.0); + let render_scale = scale as f32 * (96.0 / 72.0) * self.source.zoom(); // Compute cursor paint data in a scoped block so the layout guard is // dropped before the second layout_for_generation call below. diff --git a/loki-renderer/src/page_tile.rs b/loki-renderer/src/page_tile.rs index 08547f00..41f1a8cc 100644 --- a/loki-renderer/src/page_tile.rs +++ b/loki-renderer/src/page_tile.rs @@ -21,6 +21,9 @@ pub(crate) struct PageTileProps { pub(crate) page_index: usize, pub(crate) w: f64, pub(crate) h: f64, + /// Paginated render zoom factor — tile-local CSS px are + /// `pt × 96/72 × zoom`, so hit-test conversions divide it back out. + pub(crate) zoom: f64, pub(crate) shared_renderer: Arc>>, pub(crate) cursor_holder: Arc>>, pub(crate) selection: Option, @@ -47,6 +50,7 @@ impl PartialEq for PageTileProps { && self.page_index == other.page_index && self.w == other.w && self.h == other.h + && self.zoom == other.zoom && Arc::ptr_eq(&self.cursor_holder, &other.cursor_holder) && self.selection == other.selection && self.doc_gen == other.doc_gen @@ -118,8 +122,8 @@ pub(crate) fn PageTile(props: PageTileProps) -> Element { // which exactly equals page-local CSS pixels — no origin math needed. onmousedown: move |evt: MouseEvent| { let e = evt.element_coordinates(); - let x_pt = e.x as f32 * (72.0 / 96.0); - let y_pt = e.y as f32 * (72.0 / 96.0); + let x_pt = (e.x / props.zoom) as f32 * (72.0 / 96.0); + let y_pt = (e.y / props.zoom) as f32 * (72.0 / 96.0); // Right-click → context menu (carry client coords to anchor it); // any other button → cursor placement / drag origin. if evt.trigger_button() == Some(MouseButton::Secondary) { @@ -142,8 +146,8 @@ pub(crate) fn PageTile(props: PageTileProps) -> Element { return; } let e = evt.element_coordinates(); - let x_pt = e.x as f32 * (72.0 / 96.0); - let y_pt = e.y as f32 * (72.0 / 96.0); + let x_pt = (e.x / props.zoom) as f32 * (72.0 / 96.0); + let y_pt = (e.y / props.zoom) as f32 * (72.0 / 96.0); on_tile_drag.call((page_index, x_pt, y_pt)); }, canvas { diff --git a/loki-renderer/src/view_types.rs b/loki-renderer/src/view_types.rs new file mode 100644 index 00000000..3137ae49 --- /dev/null +++ b/loki-renderer/src/view_types.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! View-facing types for [`super::document_view`]: the view mode, cursor and +//! selection positions, tile context, and [`DocumentViewProps`]. Extracted to +//! keep `document_view.rs` under the file-size ceiling. + +use std::sync::Arc; + +use dioxus::prelude::*; +use loki_doc_model::document::Document; +use loki_layout::PaginatedLayout; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum ViewMode { + /// Fixed print layout — one fixed-size page tile per page (needs the GPU + /// paint path). This is the default on large viewports. + #[default] + Paginated, + /// Reflowable, web-page-style continuous layout that wraps to the viewport + /// width. The default on small viewports, and the only mode available on + /// the Android CPU path. + Reflow, +} + +// ── RendererCursorPos ───────────────────────────────────────────────────────── + +/// Minimal cursor position for GPU painting. No Loro dependency. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RendererCursorPos { + pub page_index: usize, + pub paragraph_index: usize, + pub byte_offset: usize, +} + +/// Caret + optional range selection for GPU painting. `anchor == focus` (by +/// paragraph/byte) means a collapsed caret with no selection. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RendererSelection { + pub focus: RendererCursorPos, + pub anchor: RendererCursorPos, +} + +impl RendererSelection { + /// True when there is no range selection (anchor and focus coincide). + pub fn is_collapsed(&self) -> bool { + self.anchor.paragraph_index == self.focus.paragraph_index + && self.anchor.byte_offset == self.focus.byte_offset + } +} + +/// A right-click on a page tile, carrying both the tile-local layout-point +/// coordinates (for an accurate hit test) and the window-relative client +/// coordinates (to anchor a floating menu at the cursor). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TileContext { + /// Index of the page tile that was right-clicked. + pub page_index: usize, + /// X within the tile, in layout points (from `element_coordinates`). + pub x_pt: f32, + /// Y within the tile, in layout points. + pub y_pt: f32, + /// Window-relative X of the cursor, in CSS pixels. + pub client_x: f32, + /// Window-relative Y of the cursor, in CSS pixels. + pub client_y: f32, +} + +// ── DocumentViewProps ───────────────────────────────────────────────────────── + +/// Props for the DocumentView component. +#[derive(Props, Clone)] +pub struct DocumentViewProps { + pub doc: Arc, + /// Paginated layout already computed by the editor for `doc`, reused in + /// paginated mode so the renderer does not lay the document out a second + /// time (the single canonical layout). `None` falls back to computing it + /// on the render path (e.g. before the first editor layout, or on the + /// Android CPU reflow path). + pub paginated_layout: Option>, + pub viewport_height_px: f64, + /// Current vertical scroll offset of the editor's scroll container, in CSS + /// px. Drives tile virtualization: only pages within ~one screen of this + /// offset are GPU-rendered. The editor owns the scroll container (this + /// component is laid out inside it), so the scroll position must be passed + /// in — the renderer's own scroll signal is not updated by the real scroll. + pub viewport_top_px: f64, + /// The caret / selection focus position. + pub cursor_pos: Option, + /// The selection anchor. When it differs from `cursor_pos`, a range + /// selection is highlighted between them (reflow mode). + pub selection_anchor: Option, + /// Current layout mode. Ignored on the Android CPU path, which only supports + /// [`ViewMode::Reflow`]. + pub view_mode: ViewMode, + /// Available viewport width in CSS pixels for [`ViewMode::Reflow`]. + /// `<= 0` means "not yet measured" — the view falls back to paginated + /// rendering until a real width arrives. + pub reflow_width_px: f64, + /// Paginated render zoom factor (1.0 = 100%). Scales the page tiles' CSS + /// size and paint transform together; the layout (in points) and reflow + /// mode are unaffected. + #[props(default = 1.0)] + pub zoom: f64, + /// Called with `(page_index, x_pt, y_pt)` in layout points when the user + /// clicks a page tile in **paginated** mode. The caller performs the hit test + /// and updates cursor state. + pub on_tile_click: EventHandler<(usize, f32, f32)>, + /// Called with `(block_index, byte_offset)` when the user clicks in + /// **reflow** mode. This component owns the reflow layout, so it hit-tests + /// the click itself and reports the resolved document position. + pub on_reflow_click: EventHandler<(usize, usize)>, + /// Called with `(block_index, byte_offset)` while drag-selecting in + /// **reflow** mode (mouse moved with a button held). The caller extends the + /// selection focus to this position. + pub on_reflow_drag: EventHandler<(usize, usize)>, + /// Called when a page tile is right-clicked (paginated mode), carrying the + /// accurate tile-local + client coordinates. Drives the spelling context menu. + pub on_tile_context: EventHandler, + /// Vertical gap between page tiles in paginated mode, in CSS px. Injected by + /// the app (from `appthere_ui::tokens::PAGE_GAP_PX`) rather than imported + /// here, so the render layer does not depend on the UI layer — Spec 01 audit + /// A-8. Zeroed automatically in reflow mode. + pub page_gap_px: f64, + /// Bottom padding below the last page, in CSS px (from + /// `appthere_ui::tokens::SPACE_6`). Injected for the same reason as + /// `page_gap_px`. + pub content_padding_bottom_px: f32, +} + +impl PartialEq for DocumentViewProps { + fn eq(&self, _other: &Self) -> bool { + false // Conservatively always re-render + } +} diff --git a/loki-spreadsheet/src/routes/editor/editor_inner.rs b/loki-spreadsheet/src/routes/editor/editor_inner.rs index 506c045c..95cdd472 100644 --- a/loki-spreadsheet/src/routes/editor/editor_inner.rs +++ b/loki-spreadsheet/src/routes/editor/editor_inner.rs @@ -3,10 +3,9 @@ //! Spreadsheet editor inner view. -use appthere_ui::tokens; use appthere_ui::{ AtIcon, AtRibbon, AtRibbonGroup, AtRibbonIconButton, AtStatusBar, LUCIDE_BOLD, LUCIDE_ITALIC, - LUCIDE_REDO, LUCIDE_UNDERLINE, LUCIDE_UNDO, RibbonTabDesc, + LUCIDE_REDO, LUCIDE_UNDERLINE, LUCIDE_UNDO, RibbonTabDesc, tokens, }; use dioxus::prelude::*; use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; @@ -1123,6 +1122,7 @@ pub(super) fn EditorInner(path: String) -> Element { collaborator_count: 0, collaborator_label: String::new(), zoom_aria_label: fl!("editor-zoom-aria"), + // TODO(zoom): needs zoom-aware col_px + resize px↔pt (plan 4c.5). on_zoom_click: |_| {}, } } diff --git a/loki-text/src/editing/mod.rs b/loki-text/src/editing/mod.rs index 575799ba..a9fae788 100644 --- a/loki-text/src/editing/mod.rs +++ b/loki-text/src/editing/mod.rs @@ -21,3 +21,4 @@ pub mod spell; pub mod state; pub mod touch; pub mod viewport; +pub mod word_count; diff --git a/loki-text/src/editing/word_count.rs b/loki-text/src/editing/word_count.rs new file mode 100644 index 00000000..9df6481d --- /dev/null +++ b/loki-text/src/editing/word_count.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Live word count for the status bar (audit F7c / plan 4c.5). +//! +//! [`count_words`] streams over the document's display text without +//! allocating: a word is a maximal run of non-whitespace characters, and +//! adjacent inline runs continue the same word (`"Hel"` + bold `"lo"` is one +//! word), while block boundaries, spaces, and line breaks end it. Matching +//! Word's status-bar semantics, table cells and figure captions are counted; +//! footnote/endnote bodies, comments, and generated content (TOC, index) are +//! not. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::Inline; +use loki_doc_model::document::Document; +use loki_i18n::fl; + +use super::cursor::CursorState; +use super::state::DocumentState; + +#[cfg(test)] +#[path = "word_count_tests.rs"] +mod tests; + +/// Streaming word-count state: `in_word` carries across adjacent text runs so +/// styling boundaries inside a word don't split it. +#[derive(Default)] +struct Counter { + words: usize, + in_word: bool, +} + +impl Counter { + fn text(&mut self, s: &str) { + for c in s.chars() { + if c.is_whitespace() { + self.in_word = false; + } else if !self.in_word { + self.words += 1; + self.in_word = true; + } + } + } + + fn separator(&mut self) { + self.in_word = false; + } +} + +/// Counts the words in `doc`'s display text. See the module docs for what is +/// and is not counted. +#[must_use] +pub fn count_words(doc: &Document) -> usize { + let mut counter = Counter::default(); + for section in &doc.sections { + count_blocks(§ion.blocks, &mut counter); + } + counter.words +} + +fn count_blocks(blocks: &[Block], counter: &mut Counter) { + for block in blocks { + counter.separator(); + match block { + Block::Plain(inlines) | Block::Para(inlines) | Block::Heading(_, _, inlines) => { + count_inlines(inlines, counter); + } + Block::StyledPara(p) => count_inlines(&p.inlines, counter), + Block::LineBlock(lines) => { + for line in lines { + counter.separator(); + count_inlines(line, counter); + } + } + Block::CodeBlock(_, code) => counter.text(code), + Block::BlockQuote(inner) | Block::Div(_, inner) => count_blocks(inner, counter), + Block::OrderedList(_, items) | Block::BulletList(items) => { + for item in items { + count_blocks(item, counter); + } + } + Block::DefinitionList(defs) => { + for (term, definitions) in defs { + counter.separator(); + count_inlines(term, counter); + for def in definitions { + count_blocks(def, counter); + } + } + } + Block::Table(table) => { + for row in table + .head + .rows + .iter() + .chain(table.bodies.iter().flat_map(|b| b.body_rows.iter())) + .chain(table.foot.rows.iter()) + { + for cell in &row.cells { + count_blocks(&cell.blocks, counter); + } + } + } + Block::Figure(_, _, content) => count_blocks(content, counter), + // Generated or non-text content (and, `#[non_exhaustive]`, any + // future block kind until it is classified) contributes nothing. + _ => {} + } + } +} + +fn count_inlines(inlines: &[Inline], counter: &mut Counter) { + for inline in inlines { + match inline { + Inline::Str(s) => counter.text(s), + Inline::Emph(inner) + | Inline::Underline(inner) + | Inline::Strong(inner) + | Inline::Strikeout(inner) + | Inline::Superscript(inner) + | Inline::Subscript(inner) + | Inline::SmallCaps(inner) + | Inline::Quoted(_, inner) + | Inline::Cite(_, inner) + | Inline::Span(_, inner) + | Inline::Link(_, inner, _) => count_inlines(inner, counter), + Inline::StyledRun(run) => count_inlines(&run.content, counter), + Inline::Code(_, code) => counter.text(code), + Inline::Space | Inline::SoftBreak | Inline::LineBreak => counter.separator(), + // Word's status-bar count excludes footnote/endnote bodies; image + // alt text, raw passthrough, math, fields, comments, and bookmark + // markers are not display words either. (`#[non_exhaustive]` + // future inline kinds land here too, as separators.) + _ => counter.separator(), + } + } +} + +/// Memoised, localised status-bar word-count label (`editor-word-count`), +/// recomputed after every document mutation (the cursor's mirrored +/// `document_generation` is the change signal). +pub fn use_word_count_label( + doc_state: Arc>, + cursor_state: Signal, +) -> Memo { + use_memo(move || { + let _generation = cursor_state.read().document_generation; + let count = doc_state + .lock() + .ok() + .and_then(|s| s.document.as_ref().map(|d| count_words(d))) + .unwrap_or(0); + fl!("editor-word-count", count = count as i64) + }) +} diff --git a/loki-text/src/editing/word_count_tests.rs b/loki-text/src/editing/word_count_tests.rs new file mode 100644 index 00000000..a6679420 --- /dev/null +++ b/loki-text/src/editing/word_count_tests.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use loki_doc_model::content::attr::NodeAttr; +use loki_doc_model::content::block::Block; +use loki_doc_model::content::inline::{Inline, NoteKind}; +use loki_doc_model::content::table::core::{Table, TableBody, TableCaption, TableFoot, TableHead}; +use loki_doc_model::content::table::row::{Cell, Row}; +use loki_doc_model::document::Document; + +use super::count_words; + +fn doc_with(blocks: Vec) -> Document { + let mut doc = Document::new(); + doc.sections[0].blocks = blocks; + doc +} + +#[test] +fn counts_whitespace_separated_words() { + let doc = doc_with(vec![Block::Para(vec![Inline::Str( + "the quick brown fox".into(), + )])]); + assert_eq!(count_words(&doc), 4); +} + +#[test] +fn empty_document_counts_zero() { + assert_eq!(count_words(&doc_with(vec![])), 0); + assert_eq!( + count_words(&doc_with(vec![Block::Para(vec![Inline::Str( + " ".into() + )])])), + 0 + ); +} + +#[test] +fn styling_inside_a_word_does_not_split_it() { + // "Hel" + bold "lo" + " world" = 2 words, not 3. + let doc = doc_with(vec![Block::Para(vec![ + Inline::Str("Hel".into()), + Inline::Strong(vec![Inline::Str("lo".into())]), + Inline::Str(" world".into()), + ])]); + assert_eq!(count_words(&doc), 2); +} + +#[test] +fn explicit_space_and_break_inlines_separate_words() { + let doc = doc_with(vec![Block::Para(vec![ + Inline::Str("one".into()), + Inline::Space, + Inline::Str("two".into()), + Inline::LineBreak, + Inline::Str("three".into()), + ])]); + assert_eq!(count_words(&doc), 3); +} + +#[test] +fn block_boundaries_separate_words() { + // "…end" | "start…" must not merge into one word across paragraphs. + let doc = doc_with(vec![ + Block::Para(vec![Inline::Str("end".into())]), + Block::Para(vec![Inline::Str("start".into())]), + ]); + assert_eq!(count_words(&doc), 2); +} + +#[test] +fn table_cells_are_counted() { + let table = Table { + attr: NodeAttr::default(), + caption: TableCaption::default(), + width: None, + col_specs: Vec::new(), + head: TableHead::empty(), + bodies: vec![TableBody::from_rows(vec![Row::new(vec![ + Cell::simple(vec![Block::Para(vec![Inline::Str("alpha beta".into())])]), + Cell::simple(vec![Block::Para(vec![Inline::Str("gamma".into())])]), + ])])], + foot: TableFoot::empty(), + }; + let doc = doc_with(vec![ + Block::Para(vec![Inline::Str("intro".into())]), + Block::Table(Box::new(table)), + ]); + assert_eq!(count_words(&doc), 4); +} + +#[test] +fn footnote_bodies_are_excluded() { + // Matches Word's status-bar semantics (footnotes not included). + let doc = doc_with(vec![Block::Para(vec![ + Inline::Str("body".into()), + Inline::Note( + NoteKind::Footnote, + vec![Block::Para(vec![Inline::Str("hidden note words".into())])], + ), + Inline::Str("more".into()), + ])]); + assert_eq!(count_words(&doc), 2); +} + +#[test] +fn lists_and_headings_are_counted() { + let doc = doc_with(vec![ + Block::Heading(1, NodeAttr::default(), vec![Inline::Str("Title".into())]), + Block::BulletList(vec![ + vec![Block::Para(vec![Inline::Str("first item".into())])], + vec![Block::Para(vec![Inline::Str("second".into())])], + ]), + ]); + assert_eq!(count_words(&doc), 4); +} diff --git a/loki-text/src/routes/editor/editor_canvas.rs b/loki-text/src/routes/editor/editor_canvas.rs index bd25bdf5..8c341b5e 100644 --- a/loki-text/src/routes/editor/editor_canvas.rs +++ b/loki-text/src/routes/editor/editor_canvas.rs @@ -31,8 +31,12 @@ use std::sync::Arc; use appthere_ui::tokens; +use dioxus::html::input_data::MouseButton; use dioxus::prelude::*; +use loki_app_shell::spell::SpellService; use loki_doc_model::document::Document; +use loki_doc_model::loro_bridge::derive_loro_cursor; +use loki_i18n::fl; use loki_renderer::{DocumentView, RendererCursorPos, TileContext, ViewMode}; use super::editor_error_view::EditorErrorView; @@ -43,17 +47,10 @@ use super::editor_pointer::{ use super::editor_scrollbar::{ CanvasMounted, ScrollMetrics, ThumbDrag, horizontal_scrollbar, vertical_scrollbar, }; -use crate::editing::cursor::{CursorState, DocumentPosition}; -use crate::editing::hit_test::hit_test_page; -use crate::editing::state::DocumentState; -use crate::editing::touch::TouchInteractionState; -use dioxus::html::input_data::MouseButton; -use loki_app_shell::spell::SpellService; - use super::editor_spell::{SpellMenu, resolve_spell_menu}; +use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::editing::{hit_test::hit_test_page, state::DocumentState, touch::TouchInteractionState}; use crate::error::LoadError; -use loki_doc_model::loro_bridge::derive_loro_cursor; -use loki_i18n::fl; /// Fallback viewport height (CSS px) for tile virtualization before the scroll /// container is first measured. A named default — not a hardcoded screen @@ -177,6 +174,7 @@ pub(super) fn render_canvas_area( service: SpellService, spell_menu: Signal>, doc_state_context: Arc>, + zoom_percent: Signal, ) -> Element { rsx! { // Outer wrapper occupies the editor column's flex:1 slot and lays out @@ -376,6 +374,7 @@ pub(super) fn render_canvas_area( DocumentView { doc: arc_doc, paginated_layout, + zoom: zoom_percent() as f64 / 100.0, // Real measured viewport height (falls back to a // sensible default before the first measure). Drives // tile virtualization: only pages within ~one screen diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index 8fc001b8..878bb952 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -22,8 +22,7 @@ use std::rc::Rc; use std::sync::Arc; -use appthere_ui::tokens; -use appthere_ui::{AtRibbon, AtStatusBar, RibbonTabDesc, use_breakpoint}; +use appthere_ui::{AtRibbon, AtStatusBar, RibbonTabDesc, tokens, use_breakpoint}; use dioxus::prelude::*; use loki_doc_model::document::Document; use loki_doc_model::get_mark_at; @@ -45,7 +44,6 @@ use super::editor_path_sync::{ use super::editor_publish::{publish_panel, publish_tab_content}; use super::editor_ribbon::write_tab_content; use super::editor_ribbon_insert::insert_tab_content; -use super::editor_save::save_document_to_path; use super::editor_save_banner::save_banner; use super::editor_spell::SpellMenu; use super::editor_state::{EditorState, use_editor_state}; @@ -108,7 +106,8 @@ pub(super) fn EditorInner(path: String) -> Element { can_redo, is_style_picker_open, editing_style_draft, - mut save_message, + mut zoom_percent, + save_message, save_request, mut active_ribbon_tab, is_publish_panel_open, @@ -455,6 +454,10 @@ pub(super) fn EditorInner(path: String) -> Element { // blitz-dom / dioxus-native-dom) whenever a wheel or touch gesture changes // the scroll container's offset. + // Live status-bar word count, recomputed per mutation (F7c / 4c.5). + let word_count_label = + crate::editing::word_count::use_word_count_label(Arc::clone(&doc_state), cursor_state); + // ── Unsaved-changes (dirty) tracking ───────────────────────────────────── // // The tab shows a dirty indicator when the live document generation differs @@ -506,45 +509,20 @@ pub(super) fn EditorInner(path: String) -> Element { save_message, }; - // ── Ctrl+S handler ─────────────────────────────────────────────────────── - // - // The keydown handler bumps `save_request`; perform the save here, where the - // tab/recents context is reachable. Untitled documents route to Save As. - let doc_state_savereq = Arc::clone(&doc_state); - use_effect(move || { - let n = save_request(); // subscribe — fires on each Ctrl+S - if n == 0 { - return; // initial value — nothing requested yet - } - let path = path_signal.peek().clone(); - if is_untitled(&path) { - save_as.call(()); - return; - } - let msg = match save_document_to_path(&path, &doc_state_savereq) { - Ok(()) => { - baseline_gen.set(cursor_state.peek().document_generation); - // Record the undo-stack clean checkpoint: undoing back to - // this depth means the document equals the file again. - if let Some(um) = undo_manager.write().as_mut() { - let _ = um.record_new_checkpoint(); - } - saved_state.peek().mark_saved(); - // The file is now the durable state — bound the CRDT history - // memory (memory-audit Finding 6; see editor_compact.rs). - super::editor_compact::compact_after_save( - loro_doc, - undo_manager, - saved_state, - can_undo, - can_redo, - &doc_state_savereq, - ); - fl!("editor-save-success") - } - Err(e) => fl!("editor-save-error", reason = e.to_string()), - }; - save_message.set(Some(msg)); + // ── Ctrl+S handler (extracted flow — see editor_save_callbacks) ───────── + super::editor_save_callbacks::use_ctrl_s_save(super::editor_save_callbacks::CtrlSCtx { + doc_state: Arc::clone(&doc_state), + path_signal, + save_request, + save_as, + baseline_gen, + cursor_state, + loro_doc, + undo_manager, + saved_state, + can_undo, + can_redo, + save_message, }); // ── Viewport-driven effects (Spec 03 M1/M2) ────────────────────────────── @@ -634,6 +612,7 @@ pub(super) fn EditorInner(path: String) -> Element { spell_service.clone(), spell_menu, doc_state_spell_ctx, + zoom_percent, )} // ── Font-substitution warning (Spec 03 M3) ──────────────────────── @@ -795,13 +774,16 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Status bar ──────────────────────────────────────────────────── AtStatusBar { page_label: page_label, - word_count_label: "".to_string(), + word_count_label: word_count_label(), language_label: fl!("editor-language"), - zoom_percent: 100, + zoom_percent: zoom_percent(), collaborator_count: 0, collaborator_label: String::new(), zoom_aria_label: fl!("editor-zoom-aria"), - on_zoom_click: |_| {}, + on_zoom_click: move |_| { + let next = appthere_ui::next_zoom(*zoom_percent.peek()); + zoom_percent.set(next); + }, view_mode_label: if view_mode() == ViewMode::Reflow { fl!("editor-view-reflowed") } else { diff --git a/loki-text/src/routes/editor/editor_save_callbacks.rs b/loki-text/src/routes/editor/editor_save_callbacks.rs index d9ebd504..9b98f8a7 100644 --- a/loki-text/src/routes/editor/editor_save_callbacks.rs +++ b/loki-text/src/routes/editor/editor_save_callbacks.rs @@ -124,3 +124,75 @@ pub(super) fn use_save_as_template_callback( }); }) } + +/// Signals the Ctrl+S flow reads and writes, grouped for the hook call. +pub(super) struct CtrlSCtx { + pub doc_state: Arc>, + pub path_signal: Signal, + pub save_request: Signal, + pub save_as: Callback<()>, + pub baseline_gen: Signal, + pub cursor_state: Signal, + pub loro_doc: Signal>, + pub undo_manager: Signal>, + pub saved_state: Signal, + pub can_undo: Signal, + pub can_redo: Signal, + pub save_message: Signal>, +} + +/// The Ctrl+S save effect: the keydown handler bumps `save_request`; the save +/// runs here, where the tab/recents context is reachable. Untitled documents +/// route to Save As. On success the clean baseline + undo checkpoint are +/// recorded and the CRDT history is compacted (see `editor_compact`). +pub(super) fn use_ctrl_s_save(ctx: CtrlSCtx) { + let CtrlSCtx { + doc_state, + path_signal, + save_request, + save_as, + mut baseline_gen, + cursor_state, + loro_doc, + mut undo_manager, + saved_state, + can_undo, + can_redo, + mut save_message, + } = ctx; + use_effect(move || { + let n = save_request(); // subscribe — fires on each Ctrl+S + if n == 0 { + return; // initial value — nothing requested yet + } + let path = path_signal.peek().clone(); + if crate::new_document::is_untitled(&path) { + save_as.call(()); + return; + } + let msg = match super::editor_save::save_document_to_path(&path, &doc_state) { + Ok(()) => { + baseline_gen.set(cursor_state.peek().document_generation); + // Record the undo-stack clean checkpoint: undoing back to + // this depth means the document equals the file again. + if let Some(um) = undo_manager.write().as_mut() { + let _ = um.record_new_checkpoint(); + } + saved_state.peek().mark_saved(); + // The file is now the durable state — bound the CRDT history + // memory (memory-audit Finding 6; see editor_compact.rs). + super::editor_compact::compact_after_save( + loro_doc, + undo_manager, + saved_state, + can_undo, + can_redo, + &doc_state, + ); + fl!("editor-save-success") + } + Err(e) => fl!("editor-save-error", reason = e.to_string()), + }; + save_message.set(Some(msg)); + }); +} diff --git a/loki-text/src/routes/editor/editor_state.rs b/loki-text/src/routes/editor/editor_state.rs index 2368b211..2ee05f1f 100644 --- a/loki-text/src/routes/editor/editor_state.rs +++ b/loki-text/src/routes/editor/editor_state.rs @@ -130,6 +130,9 @@ pub(super) struct EditorState { pub is_style_picker_open: Signal, /// Style catalog editor draft — `Some` when the editor panel is open. pub editing_style_draft: Signal>, + /// Paginated render zoom, in percent (100 = 1:1). Cycled by the status + /// bar's zoom badge; scales page tiles + paint together (4c.5 / F6d). + pub zoom_percent: Signal, /// Last save result message (`None` = nothing to show). pub save_message: Signal>, /// Monotonic counter bumped by the Ctrl+S handler. `EditorInner` watches it @@ -196,6 +199,7 @@ pub(super) fn use_editor_state() -> EditorState { can_redo: use_signal(|| false), is_style_picker_open: use_signal(|| false), editing_style_draft: use_signal(|| None), + zoom_percent: use_signal(|| 100_u32), save_message: use_signal(|| None), save_request: use_signal(|| 0_u32), active_ribbon_tab: use_signal(|| 0_usize), diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index 83749220..9904ed88 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -12,18 +12,17 @@ 1073 loki-ooxml/src/docx/write/document.rs 978 loki-layout/src/resolve.rs 948 loki-vello/src/scene.rs -832 loki-text/src/routes/editor/editor_inner.rs +814 loki-text/src/routes/editor/editor_inner.rs 741 loki-layout/src/flow_para.rs 730 loki-ooxml/src/docx/mapper/inline.rs 651 loki-odf/src/package.rs 611 loki-ooxml/src/docx/mapper/document.rs 582 loki-ooxml/src/docx/mapper/props.rs 575 loki-ooxml/src/xlsx/import.rs -491 loki-text/src/routes/editor/editor_canvas.rs +490 loki-text/src/routes/editor/editor_canvas.rs 466 loki-ooxml/src/xlsx/export.rs 465 loki-odf/src/ods/import.rs 440 loki-doc-model/src/document.rs -392 loki-renderer/src/document_view.rs 388 loki-ooxml/src/docx/import.rs 373 loki-layout/src/incremental.rs 365 loki-odf/src/ods/export.rs From 8d0a6fc310be5b2bcac189b4f82212c3a834bace Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 00:46:20 +0000 Subject: [PATCH 24/27] Hooks-in-loops fix, spreadsheet ribbon, AtHomeTab breakpoint (4c.5 batch, part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more 4c.5 items from the plan (audit F6a/F6d/F7a): F6a — hooks in conditionals/loops (appthere-ui): - AtRecentFileList wrapped its per-row hover use_signal inside the document `for` loop and the empty/full-list `if` arms, so the component's hook count varied with its props (a Dioxus rule-of-hooks violation). The row is now a RecentRow #[component] and the two Open-File buttons an OpenFileButton #[component], each owning its hook scope; the parent only holds the shared open-menu index. Rows key on the document path. - AtTemplateGallery had the same shape (a hover signal per card in the `for` loop plus one in the browse `if`). The cards are now TemplateCard / BrowseCard #[component]s. F6d ribbon (loki-spreadsheet): - The ribbon listed five tabs (Home/Insert/Format/Review/View) but only Home had content and both on_tab_select and on_toggle_collapse were no-ops. It now lists just the implemented Home tab and wires the active-tab + collapse signals for real (the loki-text convention: only tabs with content are listed). F7a (appthere-ui): - AtHomeTab derived its side-by-side-vs-stacked layout from a fixed `viewport_width = 375.0` signal, locking every surface to the phone layout. It now reads use_breakpoint() (Compact = stacked, Medium/Expanded = side-by-side). loki-text already provides the responsive context; the two smaller apps don't push a measured width yet, so use_breakpoint falls back to Expanded there (noted as the plan tail). Ceiling: the spreadsheet save flow moved to editor_save.rs so the ribbon-chrome signals didn't push editor_inner.rs over; baseline unchanged at 32. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- appthere-ui/src/components/home_tab/mod.rs | 17 +- .../src/components/home_tab/recent_files.rs | 305 ++++++------------ .../src/components/home_tab/recent_row.rs | 161 +++++++++ .../components/home_tab/template_gallery.rs | 235 ++++++++------ docs/deferred-features-audit-2026-07-04.md | 4 +- docs/deferred-features-plan-2026-07-04.md | 2 +- .../src/routes/editor/editor_inner.rs | 115 +------ .../src/routes/editor/editor_save.rs | 101 ++++++ loki-spreadsheet/src/routes/editor/mod.rs | 1 + scripts/file-ceiling-baseline.txt | 2 +- 10 files changed, 532 insertions(+), 411 deletions(-) create mode 100644 appthere-ui/src/components/home_tab/recent_row.rs create mode 100644 loki-spreadsheet/src/routes/editor/editor_save.rs diff --git a/appthere-ui/src/components/home_tab/mod.rs b/appthere-ui/src/components/home_tab/mod.rs index 93b05d1e..ee479019 100644 --- a/appthere-ui/src/components/home_tab/mod.rs +++ b/appthere-ui/src/components/home_tab/mod.rs @@ -10,6 +10,7 @@ //! strings from `loki_i18n::fl!()` can be passed directly. mod recent_files; +mod recent_row; mod template_gallery; use dioxus::prelude::*; @@ -17,13 +18,14 @@ use loki_i18n::fl; use recent_files::AtRecentFileList; use template_gallery::AtTemplateGallery; +use crate::responsive::use_breakpoint; use crate::safe_area::use_safe_area; use crate::tokens::colors::{ COLOR_ACCENT_PRIMARY, COLOR_ACCENT_PRIMARY_HOVER, COLOR_STATUS_ERROR_BG, COLOR_STATUS_ERROR_BORDER, COLOR_STATUS_ERROR_TEXT, COLOR_SURFACE_BASE, COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, COLOR_TEXT_PRIMARY, }; -use crate::tokens::layout::{BREAKPOINT_DESKTOP_PX, TAB_BAR_HEIGHT}; +use crate::tokens::layout::TAB_BAR_HEIGHT; use crate::tokens::spacing::{RADIUS_SM, SPACE_2, SPACE_3, SPACE_4, SPACE_6, TOUCH_MIN}; use crate::tokens::typography::{ FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD, @@ -61,15 +63,20 @@ pub struct RecentDocument { /// Home tab content surface. /// /// Renders a header, a template gallery, and a recent documents list. -/// On viewports wider than [`BREAKPOINT_DESKTOP_PX`] the gallery and list -/// are placed side-by-side; on narrower viewports they stack vertically. +/// At the Compact size class (`use_breakpoint`, < 600 px) the gallery and +/// list stack vertically; at Medium/Expanded they sit side-by-side. Apps +/// that provide no responsive context fall back to Expanded (desktop-first); +/// pushing a measured width from the app root is what makes this live +/// (loki-text does; see the plan 4c.5 tail for the other apps). /// /// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** /// All interactive elements (template cards, document rows, buttons) meet this. #[component] pub fn AtHomeTab(props: AtHomeTabProps) -> Element { - let viewport_width = use_signal(|| 375.0_f32); - let is_desktop = viewport_width() >= BREAKPOINT_DESKTOP_PX; + // F7a: the size class comes from the shared responsive context — the old + // fixed `viewport_width = 375.0` signal locked this surface to the + // stacked phone layout everywhere. + let is_desktop = !use_breakpoint().is_compact(); // Taffy does not propagate a definite height from a flex:1 child back to // its own children's flex:1 items. Match the Shell outlet's height exactly diff --git a/appthere-ui/src/components/home_tab/recent_files.rs b/appthere-ui/src/components/home_tab/recent_files.rs index 40891ae0..c89d220e 100644 --- a/appthere-ui/src/components/home_tab/recent_files.rs +++ b/appthere-ui/src/components/home_tab/recent_files.rs @@ -1,18 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 //! `AtRecentFileList` — recent documents list with per-row context menu. +//! +//! Rows and the hoverable Open-File button are child `#[component]`s +//! ([`super::recent_row::RecentRow`], [`OpenFileButton`]) so each owns its +//! hook scope — the hover signals used to be `use_signal` calls inside the +//! list's `for` loop and `if` arms, making this component's hook count depend +//! on its props (audit F6a / ADR-0013). use dioxus::prelude::*; +use super::recent_row::RecentRow; use crate::components::home_tab::RecentDocument; use crate::tokens::colors::{ - COLOR_ACCENT_PRIMARY, COLOR_ACCENT_PRIMARY_HOVER, COLOR_STATUS_ERROR_TEXT, COLOR_SURFACE_PAGE, - COLOR_TEXT_ON_CHROME, COLOR_TEXT_ON_CHROME_SECONDARY, COLOR_TEXT_PRIMARY, COLOR_TEXT_SECONDARY, -}; -use crate::tokens::spacing::{RADIUS_MD, RADIUS_SM, SPACE_1, SPACE_2, SPACE_3, SPACE_4, TOUCH_MIN}; -use crate::tokens::typography::{ - FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD, + COLOR_ACCENT_PRIMARY, COLOR_ACCENT_PRIMARY_HOVER, COLOR_SURFACE_PAGE, COLOR_TEXT_ON_CHROME, + COLOR_TEXT_ON_CHROME_SECONDARY, }; +use crate::tokens::spacing::{RADIUS_SM, SPACE_2, SPACE_4, TOUCH_MIN}; +use crate::tokens::typography::{FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_WEIGHT_SEMIBOLD}; /// Maximum number of recent entries displayed in the list. const RECENT_VISIBLE_LIMIT: usize = 10; @@ -29,6 +34,12 @@ const RECENT_VISIBLE_LIMIT: usize = 10; #[component] pub(crate) fn AtRecentFileList(props: AtRecentFileListProps) -> Element { let mut menu_open: Signal> = use_signal(|| None); + let close_then = move |handler: EventHandler| { + EventHandler::new(move |idx: usize| { + menu_open.set(None); + handler.call(idx); + }) + }; rsx! { div { @@ -41,215 +52,111 @@ pub(crate) fn AtRecentFileList(props: AtRecentFileListProps) -> Element { if props.documents.is_empty() { // ── Empty state ─────────────────────────────────────────────── - { - let mut open_hovered = use_signal(|| false); - let open_bg = if open_hovered() { - COLOR_ACCENT_PRIMARY_HOVER - } else { - COLOR_ACCENT_PRIMARY - }; - rsx! { - div { - style: format!( - "display: flex; flex-direction: column; \ - align-items: center; gap: {gap}px; padding: {p}px;", - gap = SPACE_4, - p = SPACE_4, - ), - span { - style: format!( - "font-size: {size}px; color: {fg};", - size = FONT_SIZE_BODY, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - ), - "{props.empty_label}" - } - button { - style: format!( - "background: {bg}; color: {fg}; \ - border: none; border-radius: {r}px; \ - min-height: {touch}px; padding: 0 {px}px; \ - font-size: {size}px; font-weight: {weight}; \ - cursor: pointer;", - bg = open_bg, - fg = COLOR_SURFACE_PAGE, - r = RADIUS_SM, - touch = TOUCH_MIN, - px = SPACE_4, - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - ), - onmouseenter: move |_| { open_hovered.set(true); }, - onmouseleave: move |_| { open_hovered.set(false); }, - onclick: move |_| { props.on_open_file.call(()); }, - "{props.open_file_label}" - } - } + div { + style: format!( + "display: flex; flex-direction: column; \ + align-items: center; gap: {gap}px; padding: {p}px;", + gap = SPACE_4, + p = SPACE_4, + ), + span { + style: format!( + "font-size: {size}px; color: {fg};", + size = FONT_SIZE_BODY, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + ), + "{props.empty_label}" + } + OpenFileButton { + label: props.open_file_label.clone(), + full_width: false, + on_click: props.on_open_file, } } } for (idx, doc) in props.documents.iter().take(RECENT_VISIBLE_LIMIT).enumerate() { - { - let title = doc.title.clone(); - let modified = doc.modified_at.clone(); - let mut row_hovered = use_signal(|| false); - let row_bg = if row_hovered() { "#F5F5F5" } else { COLOR_SURFACE_PAGE }; - let is_menu_open = menu_open() == Some(idx); - // Shared base style for context-menu action buttons; caller supplies `color`. - let action_style = |fg: &'static str| format!( - "background: transparent; border: none; text-align: left; \ - padding: {p}px {ph}px; min-height: {touch}px; cursor: pointer; \ - font-size: {size}px; color: {fg}; border-radius: {r}px;", - p = SPACE_2, ph = SPACE_3, touch = TOUCH_MIN, - size = FONT_SIZE_BODY, fg = fg, r = RADIUS_SM, - ); - rsx! { - div { - key: "{idx}", - style: format!( - "background: {bg}; border-radius: {r}px;", - bg = row_bg, - r = RADIUS_MD, - ), - onmouseenter: move |_| { row_hovered.set(true); }, - onmouseleave: move |_| { row_hovered.set(false); }, - - // ── Row: document info button + ⋮ toggle ────────── - div { - style: "display: flex; align-items: center;", - button { - "aria-label": title.clone(), - style: format!( - "background: transparent; border: none; \ - border-radius: {r}px; \ - padding: {pv}px {ph}px; min-height: {touch}px; \ - flex: 1; display: flex; flex-direction: column; \ - gap: {gap}px; cursor: pointer; \ - text-align: left; box-sizing: border-box;", - r = RADIUS_MD, - pv = SPACE_3, - ph = SPACE_4, - touch = TOUCH_MIN, - gap = SPACE_1, - ), - onclick: move |_| { props.on_select.call(idx); }, - span { - style: format!( - "font-size: {size}px; font-weight: {weight}; color: {fg};", - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - fg = COLOR_TEXT_PRIMARY, - ), - "{title}" - } - span { - style: format!( - "font-size: {size}px; color: {fg};", - size = FONT_SIZE_LABEL, - fg = COLOR_TEXT_SECONDARY, - ), - "{modified}" - } - } - // ── ⋮ context menu button ───────────────────── - button { - "aria-label": props.menu_aria_label.clone(), - "aria-expanded": if is_menu_open { "true" } else { "false" }, - style: format!( - "background: transparent; border: none; \ - min-width: {touch}px; min-height: {touch}px; \ - border-radius: {r}px; cursor: pointer; \ - font-size: 18px; color: {fg}; flex-shrink: 0;", - touch = TOUCH_MIN, - r = RADIUS_SM, - fg = COLOR_TEXT_ON_CHROME_SECONDARY, - ), - onclick: move |_| { - menu_open.set(if is_menu_open { None } else { Some(idx) }); - }, - "⋮" - } - } - - // ── Inline context menu ─────────────────────────── - if is_menu_open { - div { - style: format!( - "display: flex; flex-direction: column; \ - border-top: 1px solid #E0E0E0; \ - padding: {p}px; gap: {gap}px;", - p = SPACE_2, - gap = SPACE_1, - ), - button { - style: action_style(COLOR_TEXT_PRIMARY), - onclick: move |_| { - menu_open.set(None); - props.on_remove.call(idx); - }, - "{props.remove_label}" - } - button { - style: action_style(COLOR_STATUS_ERROR_TEXT), - onclick: move |_| { - menu_open.set(None); - props.on_delete.call(idx); - }, - "{props.delete_label}" - } - button { - style: action_style(COLOR_TEXT_PRIMARY), - onclick: move |_| { - menu_open.set(None); - props.on_open_copy.call(idx); - }, - "{props.open_copy_label}" - } - } - } - } - } + RecentRow { + key: "{doc.path}", + idx, + title: doc.title.clone(), + modified: doc.modified_at.clone(), + is_menu_open: menu_open() == Some(idx), + menu_aria_label: props.menu_aria_label.clone(), + remove_label: props.remove_label.clone(), + delete_label: props.delete_label.clone(), + open_copy_label: props.open_copy_label.clone(), + on_select: props.on_select, + on_toggle_menu: move |i: usize| { + menu_open.set(if *menu_open.peek() == Some(i) { None } else { Some(i) }); + }, + on_remove: close_then(props.on_remove), + on_delete: close_then(props.on_delete), + on_open_copy: close_then(props.on_open_copy), } } // Open File button shown below the list when documents exist if !props.documents.is_empty() { - { - let mut open_hovered = use_signal(|| false); - let open_bg = if open_hovered() { - COLOR_ACCENT_PRIMARY_HOVER - } else { - COLOR_ACCENT_PRIMARY - }; - rsx! { - button { - style: format!( - "background: {bg}; color: {fg}; \ - border: none; border-radius: {r}px; \ - min-height: {touch}px; width: 100%; \ - font-size: {size}px; font-weight: {weight}; \ - cursor: pointer; margin-top: {mt}px;", - bg = open_bg, - fg = COLOR_TEXT_ON_CHROME, - r = RADIUS_SM, - touch = TOUCH_MIN, - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - mt = SPACE_2, - ), - onmouseenter: move |_| { open_hovered.set(true); }, - onmouseleave: move |_| { open_hovered.set(false); }, - onclick: move |_| { props.on_open_file.call(()); }, - "{props.open_file_label}" - } - } + OpenFileButton { + label: props.open_file_label.clone(), + full_width: true, + on_click: props.on_open_file, } } } } } +// ── OpenFileButton ──────────────────────────────────────────────────────────── + +/// The accent "Open file…" button (hover state owned here, not by the list). +/// +/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8)** via +/// `min-height` + padding/width. +#[component] +fn OpenFileButton(label: String, full_width: bool, on_click: EventHandler<()>) -> Element { + let mut hovered = use_signal(|| false); + let bg = if hovered() { + COLOR_ACCENT_PRIMARY_HOVER + } else { + COLOR_ACCENT_PRIMARY + }; + let (fg, sizing) = if full_width { + ( + COLOR_TEXT_ON_CHROME, + format!("width: 100%; margin-top: {mt}px;", mt = SPACE_2), + ) + } else { + ( + COLOR_SURFACE_PAGE, + format!("padding: 0 {px}px;", px = SPACE_4), + ) + }; + rsx! { + button { + style: format!( + "background: {bg}; color: {fg}; \ + border: none; border-radius: {r}px; \ + min-height: {touch}px; {sizing} \ + font-size: {size}px; font-weight: {weight}; \ + cursor: pointer;", + bg = bg, + fg = fg, + r = RADIUS_SM, + touch = TOUCH_MIN, + sizing = sizing, + size = FONT_SIZE_BODY, + weight = FONT_WEIGHT_SEMIBOLD, + ), + onmouseenter: move |_| { hovered.set(true); }, + onmouseleave: move |_| { hovered.set(false); }, + onclick: move |_| { on_click.call(()); }, + "{label}" + } + } +} + // ── Props ───────────────────────────────────────────────────────────────────── #[derive(Props, Clone, PartialEq)] diff --git a/appthere-ui/src/components/home_tab/recent_row.rs b/appthere-ui/src/components/home_tab/recent_row.rs new file mode 100644 index 00000000..b3d35852 --- /dev/null +++ b/appthere-ui/src/components/home_tab/recent_row.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! One row of [`super::recent_files::AtRecentFileList`]: the document info +//! button, the ⋮ menu toggle, and the inline context menu. +//! +//! A `#[component]` (not a loop body) so it owns its hook scope — the hover +//! signal used to be a `use_signal` inside the list's `for` loop, which made +//! the parent's hook count depend on the document count (audit F6a / +//! ADR-0013). + +use dioxus::prelude::*; + +use crate::tokens::colors::{ + COLOR_STATUS_ERROR_TEXT, COLOR_SURFACE_PAGE, COLOR_TEXT_ON_CHROME_SECONDARY, + COLOR_TEXT_PRIMARY, COLOR_TEXT_SECONDARY, +}; +use crate::tokens::spacing::{RADIUS_MD, RADIUS_SM, SPACE_1, SPACE_2, SPACE_3, SPACE_4, TOUCH_MIN}; +use crate::tokens::typography::{FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD}; + +/// Props for [`RecentRow`]. The parent owns the open-menu state; the row +/// reports toggle/action clicks by index. +#[derive(Props, Clone, PartialEq)] +pub(super) struct RecentRowProps { + pub idx: usize, + pub title: String, + pub modified: String, + pub is_menu_open: bool, + pub menu_aria_label: String, + pub remove_label: String, + pub delete_label: String, + pub open_copy_label: String, + pub on_select: EventHandler, + pub on_toggle_menu: EventHandler, + pub on_remove: EventHandler, + pub on_delete: EventHandler, + pub on_open_copy: EventHandler, +} + +/// A recent-document row with its inline context menu. +/// +/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8).** +/// The row click target, the ⋮ button, and every menu action meet this. +#[component] +pub(super) fn RecentRow(props: RecentRowProps) -> Element { + let mut row_hovered = use_signal(|| false); + let row_bg = if row_hovered() { + "#F5F5F5" + } else { + COLOR_SURFACE_PAGE + }; + let idx = props.idx; + // Shared base style for context-menu action buttons; caller supplies `fg`. + let action_style = |fg: &'static str| { + format!( + "background: transparent; border: none; text-align: left; \ + padding: {p}px {ph}px; min-height: {touch}px; cursor: pointer; \ + font-size: {size}px; color: {fg}; border-radius: {r}px;", + p = SPACE_2, + ph = SPACE_3, + touch = TOUCH_MIN, + size = FONT_SIZE_BODY, + fg = fg, + r = RADIUS_SM, + ) + }; + + rsx! { + div { + style: format!( + "background: {bg}; border-radius: {r}px;", + bg = row_bg, + r = RADIUS_MD, + ), + onmouseenter: move |_| { row_hovered.set(true); }, + onmouseleave: move |_| { row_hovered.set(false); }, + + // ── Row: document info button + ⋮ toggle ────────────────────────── + div { + style: "display: flex; align-items: center;", + button { + "aria-label": props.title.clone(), + style: format!( + "background: transparent; border: none; \ + border-radius: {r}px; \ + padding: {pv}px {ph}px; min-height: {touch}px; \ + flex: 1; display: flex; flex-direction: column; \ + gap: {gap}px; cursor: pointer; \ + text-align: left; box-sizing: border-box;", + r = RADIUS_MD, + pv = SPACE_3, + ph = SPACE_4, + touch = TOUCH_MIN, + gap = SPACE_1, + ), + onclick: move |_| { props.on_select.call(idx); }, + span { + style: format!( + "font-size: {size}px; font-weight: {weight}; color: {fg};", + size = FONT_SIZE_BODY, + weight = FONT_WEIGHT_SEMIBOLD, + fg = COLOR_TEXT_PRIMARY, + ), + "{props.title}" + } + span { + style: format!( + "font-size: {size}px; color: {fg};", + size = FONT_SIZE_LABEL, + fg = COLOR_TEXT_SECONDARY, + ), + "{props.modified}" + } + } + // ── ⋮ context menu button ───────────────────────────────────── + button { + "aria-label": props.menu_aria_label.clone(), + "aria-expanded": if props.is_menu_open { "true" } else { "false" }, + style: format!( + "background: transparent; border: none; \ + min-width: {touch}px; min-height: {touch}px; \ + border-radius: {r}px; cursor: pointer; \ + font-size: 18px; color: {fg}; flex-shrink: 0;", + touch = TOUCH_MIN, + r = RADIUS_SM, + fg = COLOR_TEXT_ON_CHROME_SECONDARY, + ), + onclick: move |_| { props.on_toggle_menu.call(idx); }, + "⋮" + } + } + + // ── Inline context menu ─────────────────────────────────────────── + if props.is_menu_open { + div { + style: format!( + "display: flex; flex-direction: column; \ + border-top: 1px solid #E0E0E0; \ + padding: {p}px; gap: {gap}px;", + p = SPACE_2, + gap = SPACE_1, + ), + button { + style: action_style(COLOR_TEXT_PRIMARY), + onclick: move |_| { props.on_remove.call(idx); }, + "{props.remove_label}" + } + button { + style: action_style(COLOR_STATUS_ERROR_TEXT), + onclick: move |_| { props.on_delete.call(idx); }, + "{props.delete_label}" + } + button { + style: action_style(COLOR_TEXT_PRIMARY), + onclick: move |_| { props.on_open_copy.call(idx); }, + "{props.open_copy_label}" + } + } + } + } + } +} diff --git a/appthere-ui/src/components/home_tab/template_gallery.rs b/appthere-ui/src/components/home_tab/template_gallery.rs index f2612839..212030e0 100644 --- a/appthere-ui/src/components/home_tab/template_gallery.rs +++ b/appthere-ui/src/components/home_tab/template_gallery.rs @@ -1,12 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 //! `AtTemplateGallery` — horizontally scrollable template card row. +//! +//! The cards are child `#[component]`s so each owns its hook scope — the +//! hover signals used to be `use_signal` calls inside the gallery's `for` +//! loop and `if` arm, making the gallery's hook count depend on its props +//! (audit F6a / ADR-0013). use dioxus::prelude::*; use crate::components::home_tab::BuiltinTemplate; use crate::tokens::colors::{COLOR_ACCENT_PRIMARY, COLOR_SURFACE_PAGE, COLOR_TEXT_ON_CHROME}; -use crate::tokens::spacing::{RADIUS_LG, RADIUS_SM, SPACE_2, SPACE_3, TOUCH_MIN}; +use crate::tokens::spacing::{RADIUS_LG, RADIUS_SM, SPACE_1, SPACE_2, SPACE_3, TOUCH_MIN}; use crate::tokens::typography::{ FONT_FAMILY_UI, FONT_SIZE_BODY, FONT_SIZE_LABEL, FONT_WEIGHT_SEMIBOLD, }; @@ -35,117 +40,142 @@ pub(crate) fn AtTemplateGallery(props: AtTemplateGalleryProps) -> Element { ), for (idx, tmpl) in props.templates.iter().enumerate() { - { - let name = tmpl.name.clone(); - let fmt_label = tmpl.format_label.clone(); - let mut hovered = use_signal(|| false); - let border = if hovered() { - format!("border: 2px solid {COLOR_ACCENT_PRIMARY};") - } else { - String::new() - }; - rsx! { - button { - key: "{idx}", - "aria-label": name, - style: format!( - "flex-shrink: 0; width: 100px; min-height: {touch}px; \ - background: {bg}; border-radius: {r}px; \ - padding: {pad}px; border: none; cursor: pointer; \ - display: flex; flex-direction: column; \ - align-items: center; gap: {gap}px; \ - box-sizing: border-box; {border}", - touch = TOUCH_MIN, - bg = COLOR_SURFACE_PAGE, - r = RADIUS_LG, - pad = SPACE_3, - gap = SPACE_2, - border = border, - ), - onmouseenter: move |_| { hovered.set(true); }, - onmouseleave: move |_| { hovered.set(false); }, - onclick: move |_| { props.on_select.call(idx); }, - - // Format swatch placeholder - // TODO(icons): Replace with format-type illustration. - div { - style: format!( - "width: 60px; height: 72px; \ - background: #DDDDDD; border-radius: {r}px; \ - display: flex; align-items: flex-end; \ - justify-content: center; padding-bottom: {p}px;", - r = RADIUS_SM, - p = SPACE_1, - ), - span { - style: format!( - "font-size: {size}px; color: #888888;", - size = FONT_SIZE_LABEL, - ), - "{fmt_label}" - } - } - span { - style: format!( - "font-size: {size}px; font-weight: {weight}; \ - color: #1A1A1A; text-align: center;", - size = FONT_SIZE_LABEL, - weight = FONT_WEIGHT_SEMIBOLD, - ), - "{name}" - } - } - } + TemplateCard { + key: "{tmpl.name}", + idx, + name: tmpl.name.clone(), + format_label: tmpl.format_label.clone(), + on_select: props.on_select, } } // Browse… card — hidden when browse_label is empty. if !props.browse_label.is_empty() { - { - let mut browse_hovered = use_signal(|| false); - let browse_border = if browse_hovered() { - format!("border: 2px solid {COLOR_ACCENT_PRIMARY};") - } else { - String::new() - }; - rsx! { - button { - "aria-label": props.browse_label, - style: format!( - "flex-shrink: 0; width: 100px; min-height: {touch}px; \ - background: transparent; border-radius: {r}px; \ - padding: {pad}px; cursor: pointer; \ - display: flex; flex-direction: column; \ - align-items: center; justify-content: center; \ - gap: {gap}px; box-sizing: border-box; {border}", - touch = TOUCH_MIN, - r = RADIUS_LG, - pad = SPACE_3, - gap = SPACE_2, - border = browse_border, - ), - onmouseenter: move |_| { browse_hovered.set(true); }, - onmouseleave: move |_| { browse_hovered.set(false); }, - onclick: move |_| { props.on_browse.call(()); }, - span { - style: format!( - "font-size: {size}px; font-weight: {weight}; color: {fg};", - size = FONT_SIZE_BODY, - weight = FONT_WEIGHT_SEMIBOLD, - fg = COLOR_ON_CHROME_BROWSE, - ), - "{props.browse_label}" - } - } - } + BrowseCard { + label: props.browse_label.clone(), + on_browse: props.on_browse, } } } } } -// Fallback color constant for the browse card text (not in the main palette yet) -const COLOR_ON_CHROME_BROWSE: &str = COLOR_TEXT_ON_CHROME; +/// The hover border shared by both card kinds (`2px` accent when hovered). +fn hover_border(hovered: bool) -> String { + if hovered { + format!("border: 2px solid {COLOR_ACCENT_PRIMARY};") + } else { + String::new() + } +} + +// ── TemplateCard ────────────────────────────────────────────────────────────── + +/// One template card (hover state owned here, not by the gallery). +/// +/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8)** via +/// `min-height` on a 100 px-wide card. +#[component] +fn TemplateCard( + idx: usize, + name: String, + format_label: String, + on_select: EventHandler, +) -> Element { + let mut hovered = use_signal(|| false); + rsx! { + button { + "aria-label": name.clone(), + style: format!( + "flex-shrink: 0; width: 100px; min-height: {touch}px; \ + background: {bg}; border-radius: {r}px; \ + padding: {pad}px; border: none; cursor: pointer; \ + display: flex; flex-direction: column; \ + align-items: center; gap: {gap}px; \ + box-sizing: border-box; {border}", + touch = TOUCH_MIN, + bg = COLOR_SURFACE_PAGE, + r = RADIUS_LG, + pad = SPACE_3, + gap = SPACE_2, + border = hover_border(hovered()), + ), + onmouseenter: move |_| { hovered.set(true); }, + onmouseleave: move |_| { hovered.set(false); }, + onclick: move |_| { on_select.call(idx); }, + + // Format swatch placeholder + // TODO(icons): Replace with format-type illustration. + div { + style: format!( + "width: 60px; height: 72px; \ + background: #DDDDDD; border-radius: {r}px; \ + display: flex; align-items: flex-end; \ + justify-content: center; padding-bottom: {p}px;", + r = RADIUS_SM, + p = SPACE_1, + ), + span { + style: format!( + "font-size: {size}px; color: #888888;", + size = FONT_SIZE_LABEL, + ), + "{format_label}" + } + } + span { + style: format!( + "font-size: {size}px; font-weight: {weight}; \ + color: #1A1A1A; text-align: center;", + size = FONT_SIZE_LABEL, + weight = FONT_WEIGHT_SEMIBOLD, + ), + "{name}" + } + } + } +} + +// ── BrowseCard ──────────────────────────────────────────────────────────────── + +/// The trailing "Browse…" card (hover state owned here, not by the gallery). +/// +/// **Minimum interactive size: 44×44 logical pixels (WCAG 2.5.8)** via +/// `min-height` on a 100 px-wide card. +#[component] +fn BrowseCard(label: String, on_browse: EventHandler<()>) -> Element { + let mut hovered = use_signal(|| false); + rsx! { + button { + "aria-label": label.clone(), + style: format!( + "flex-shrink: 0; width: 100px; min-height: {touch}px; \ + background: transparent; border-radius: {r}px; \ + padding: {pad}px; cursor: pointer; \ + display: flex; flex-direction: column; \ + align-items: center; justify-content: center; \ + gap: {gap}px; box-sizing: border-box; {border}", + touch = TOUCH_MIN, + r = RADIUS_LG, + pad = SPACE_3, + gap = SPACE_2, + border = hover_border(hovered()), + ), + onmouseenter: move |_| { hovered.set(true); }, + onmouseleave: move |_| { hovered.set(false); }, + onclick: move |_| { on_browse.call(()); }, + span { + style: format!( + "font-size: {size}px; font-weight: {weight}; color: {fg};", + size = FONT_SIZE_BODY, + weight = FONT_WEIGHT_SEMIBOLD, + fg = COLOR_TEXT_ON_CHROME, + ), + "{label}" + } + } + } +} // ── Props ───────────────────────────────────────────────────────────────────── @@ -156,6 +186,3 @@ pub(crate) struct AtTemplateGalleryProps { pub on_select: EventHandler, pub on_browse: EventHandler<()>, } - -// Keep lint happy — the SPACE_1 constant is used in the swatch sub-element. -use crate::tokens::spacing::SPACE_1; diff --git a/docs/deferred-features-audit-2026-07-04.md b/docs/deferred-features-audit-2026-07-04.md index e37a033c..6ce4b82c 100644 --- a/docs/deferred-features-audit-2026-07-04.md +++ b/docs/deferred-features-audit-2026-07-04.md @@ -197,8 +197,8 @@ per-sub-item verdicts against HEAD `20b05a6`: | F3 | Edits lost on tab switch; dirty flag never set | **LARGELY RESOLVED** | Per-tab retention via `loki-text/src/sessions.rs` `DocSession` (stash/restore in `editor_path_sync.rs:42-154`); dirty tracks a generation baseline (`editor_inner.rs:465-476`), cleared on save. **Residual (F3c) resolved 2026-07-05** (plan 4b.6): closing a dirty tab raises an `AtConfirmDialog` confirmation in all three apps' shells. | | F4 | Untitled documents cannot be saved; no Ctrl+S | **RESOLVED** | Save As via `pick_file_to_save` (`editor_inner.rs:484-535`); Ctrl/Cmd+S bound (`editor_keydown.rs:60-67`) routing untitled → Save As. | | F5 | Settle/retier pipeline wired to dead channels | **RESOLVED (by removal)** | The pipeline was deleted, not fixed: virtualization now bounds memory by mounting only viewport-window pages (`virtualize.rs` `visible_window`, `document_view.rs:290`). The 06-10 audit's §5 claim of "downsample by viewport distance" was wrong about the mechanism — corrected there. **Residual:** `DocumentViewProps::eq` still hardwired `false` (`document_view.rs:143-147`) — now a benign over-render, capped by `PageTile`'s own `PartialEq`. | -| F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom wired 2026-07-05 in loki-text (GPU tile scale) + loki-presentation (slide scale); spreadsheet zoom (`TODO(zoom)`) and spreadsheet ribbon tab-select/collapse still dead**. RESOLVED 2026-07-05: F6c-selection — typing replaces the active selection and Backspace/Delete remove it, including multi-block ranges (`loki_doc_model::delete_selection_at` composes `merge_block_at` + `delete_text_at` with pre-validation, so cross-container or table-spanning ranges are rejected untouched; wired via `editor_keydown_text.rs`; tested by `loro_selection_delete_tests.rs` + editor unit tests). STILL-OPEN: F6a hooks in conditionals/loops (`recent_files.rs:45,96,219`), F6c-clipboard (copy/cut/paste — partially gated on the unimplemented dioxus-native-dom clipboard converter), F6f synchronous save/load on UI thread. | -| F7 | Low grab-bag | **PARTIAL** | RESOLVED: F7d safe-area insets (RwLock + reactive version signal + resize sensor). PARTIAL → word count now live in loki-text (2026-07-05); still empty in Calc/Slides (no meaningful doc-text count yet). RESOLVED 2026-07-05: F7b (stable slide/bullet list keys + explicit `active_idx` clamp on slide delete), F7c word count (live in loki-text's status bar; `editing/word_count.rs`). STILL-OPEN: F7a `AtHomeTab` responsive layout (`viewport_width` fixed 375.0, never adopted `use_breakpoint()`), F7e debug leftovers in vendored patches, F7f `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`). | +| F6 | Medium grab-bag | **PARTIAL** | RESOLVED: F6b hit-testing geometry (live `client_width`/`scroll_offset` via `scroll_metrics`), F6e spreadsheet (visible save errors, SUM/COUNT/AVERAGE/MIN/MAX/IF engine, dynamic grid to 500×52), F6g onscroll panic path (`convert_scroll_data` implemented; unimplemented converters have no registered handlers), F6h i18n variant-locale fallback (parsed-langid comparison + regression test). PARTIAL: F6d dead UI — loki-text ribbon tabs/collapse/template cards fixed; **zoom wired 2026-07-05 in loki-text (GPU tile scale) + loki-presentation (slide scale); spreadsheet ribbon tab-select/collapse now wired (2026-07-05) and the dead extra tabs removed; spreadsheet grid-zoom (`TODO(zoom)`) still deferred**. RESOLVED 2026-07-05: F6c-selection — typing replaces the active selection and Backspace/Delete remove it, including multi-block ranges (`loki_doc_model::delete_selection_at` composes `merge_block_at` + `delete_text_at` with pre-validation, so cross-container or table-spanning ranges are rejected untouched; wired via `editor_keydown_text.rs`; tested by `loro_selection_delete_tests.rs` + editor unit tests). RESOLVED 2026-07-05: F6a hooks-in-conditionals/loops — `recent_files.rs` + `template_gallery.rs` rows/cards are now child `#[component]`s so `use_signal` no longer lives in `for`/`if` bodies (plan 4c.5). STILL-OPEN residual:, F6c-clipboard (copy/cut/paste — partially gated on the unimplemented dioxus-native-dom clipboard converter), F6f synchronous save/load on UI thread. | +| F7 | Low grab-bag | **PARTIAL** | RESOLVED: F7d safe-area insets (RwLock + reactive version signal + resize sensor). PARTIAL → word count now live in loki-text (2026-07-05); still empty in Calc/Slides (no meaningful doc-text count yet). RESOLVED 2026-07-05: F7b (stable slide/bullet list keys + explicit `active_idx` clamp on slide delete), F7c word count (live in loki-text's status bar; `editing/word_count.rs`). PARTIAL: F7a — `AtHomeTab` now reads `use_breakpoint()` (2026-07-05); the two smaller apps still don't push a measured width so it falls back to Expanded there. STILL-OPEN: F7e debug leftovers in vendored patches, F7f `buttons ^= Main` XOR on touch end/cancel (`patches/blitz-shell/src/window.rs:1133`). | **Two stale in-code comments found during verification were fixed in the same pass:** the `TODO(undo-dirty)` parenthetical ("Save not implemented" — Save now diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index f4095b79..3e29a9f5 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -132,7 +132,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). | 4c.2 | `a11y` | ✅ **Done 2026-07-05 (bounded by bar height)** — the status bar's three interactive controls (notice chip, view-mode toggle, zoom badge) are now transparent hit areas ≥ `TOUCH_MIN` (44 px) wide × the bar's full height, with the visual chip nested inside. **Honest constraint:** the 24 px `STATUS_BAR_HEIGHT` caps the vertical target at WCAG 2.5.8 AA's 24 px minimum, below the suite's 44 px convention — meeting it fully requires a taller bar on touch platforms (design decision, deferred; documented on `AtStatusBar`). | S | | 4c.3 | `title-edit`, `browse-templates`, `tabs` | Inline-editable title; template-browser dialog; tab-driven navigation + blank-doc. | M | | 4c.4 | `icons`, `ribbon`, `theme`, `platform`, `font` | Real Tabler/SVG icons over emoji; ribbon separator variant; **light-theme tokens** (currently Dark-only); macOS traffic-light region / real OS check; verify bundled UI fonts registered. | M | -| 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | **Partially done 2026-07-05:** ✅ F7b — slide thumbnails/bullets use stable keys (`SlideId`, shape+para) and slide deletion clamps `active_idx` explicitly. ✅ F7c — live word count in loki-text's status bar (`editing/word_count.rs`: streaming counter, Word-matching semantics — tables counted, notes excluded; 8 tests; plural `editor-word-count` key). ✅ F6d-zoom (2 of 3 apps) — the status-bar zoom badge cycles 50–200% (`appthere_ui::next_zoom`): loki-text scales the GPU page tiles + paint transform together (`DocPageSource::zoom` → `DocumentViewProps::zoom` → `PageTile` hit-test divide-out; reflow unaffected by design), loki-presentation scales the slide box + text; **spreadsheet zoom deferred** (`TODO(zoom)` — needs zoom-aware `col_px` + resize px↔pt in one pass). **Remaining:** F6a hooks-in-loops (`recent_files.rs`), F6d spreadsheet ribbon tab-select/collapse, F7a `AtHomeTab` `use_breakpoint()`. Ceiling wins: `document_view.rs` split (`view_types.rs`) resolved its baseline entry (34 → 32). | M | +| 4c.5 | F6a/F6d/F7a/F7b/F7c (audit §9) | ✅ **Done 2026-07-05** (spreadsheet grid-zoom is the sole tail). ✅ F6a — recent-file rows and template cards are now child `#[component]`s (`recent_row.rs`, `RecentRow`/`OpenFileButton`/`TemplateCard`/`BrowseCard`) so their hover `use_signal`s no longer live inside `for`/`if` bodies (hook count is now prop-independent; ADR-0013). ✅ F6d ribbon — loki-spreadsheet's ribbon lists only the implemented Home tab (the dead Insert/Format/Review/View entries removed) and its tab-select + collapse are live signals. ✅ F7a — `AtHomeTab` reads `use_breakpoint()` (Compact = stacked, Medium/Expanded = side-by-side) instead of the fixed `viewport_width = 375.0`. ✅ F7b — slide thumbnails/bullets use stable keys (`SlideId`, shape+para) and slide deletion clamps `active_idx` explicitly. ✅ F7c — live word count in loki-text's status bar (`editing/word_count.rs`: streaming counter, Word-matching semantics — tables counted, notes excluded; 8 tests; plural `editor-word-count` key). ✅ F6d-zoom (2 of 3 apps) — the status-bar zoom badge cycles 50–200% (`appthere_ui::next_zoom`): loki-text scales the GPU page tiles + paint transform together (`DocPageSource::zoom` → `DocumentViewProps::zoom` → `PageTile` hit-test divide-out; reflow unaffected by design), loki-presentation scales the slide box + text; **spreadsheet grid-zoom deferred** (`TODO(zoom)` — needs zoom-aware `col_px` + resize px↔pt in one pass). Ceiling wins: `document_view.rs` split (`view_types.rs`) resolved its baseline entry, plus `editor_save.rs` extracted from the spreadsheet (baseline 34 → 32). **Remaining tail:** F7a measurement plumbing for Calc/Slides (they never push a measured width, so `use_breakpoint` falls back to Expanded there) + spreadsheet grid-zoom. | M | --- diff --git a/loki-spreadsheet/src/routes/editor/editor_inner.rs b/loki-spreadsheet/src/routes/editor/editor_inner.rs index 95cdd472..dac5cb8e 100644 --- a/loki-spreadsheet/src/routes/editor/editor_inner.rs +++ b/loki-spreadsheet/src/routes/editor/editor_inner.rs @@ -8,20 +8,18 @@ use appthere_ui::{ LUCIDE_REDO, LUCIDE_UNDERLINE, LUCIDE_UNDO, RibbonTabDesc, tokens, }; use dioxus::prelude::*; -use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; use loki_i18n::fl; use std::collections::HashSet; use super::cell_ref::{col_to_label, grid_dimensions}; -use super::editor_load::{DocumentFormat, detect_format, load_document}; +use super::editor_load::load_document; use super::editor_mutate::{mutate_cell, mutate_cell_style, mutate_column_width}; use super::editor_path_sync::{ PathSyncSignals, restore_session, stash_outgoing, sync_path_and_reset, }; +use super::editor_save::save_document; use super::editor_state::{EditorState, use_editor_state}; use super::formula::{evaluate_cell, format_evaluated_value}; -use crate::routes::Route; -use crate::routes::dioxus_router::Navigator; use crate::utils::display_title_from_path; /// Default rendered column width in CSS px (when the document specifies none). @@ -103,92 +101,6 @@ fn sync_undo_redo( } } -/// Saves the workbook snapshot to the file target (or picks a target first if untitled) -fn save_document( - _path_prop: String, - mut path_signal: Signal, - workbook_snap: Signal, - mut tabs: Signal>, - active_tab: Signal, - navigator: Navigator, - mut save_message: Signal>, -) { - let active_tab_idx = *active_tab.peek(); - let current_path = path_signal.peek().clone(); - let wb = workbook_snap.peek().clone(); - - spawn(async move { - save_message.set(None); // clear any previous status - let token = if crate::new_document::is_untitled(¤t_path) { - let picker = FilePicker::new(); - let opts = SaveOptions { - mime_type: Some( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string(), - ), - suggested_name: Some("Workbook.xlsx".to_string()), - }; - match picker.pick_file_to_save(opts).await { - Ok(Some(t)) => t, - Ok(None) => return, // user cancelled — not an error - Err(e) => { - save_message.set(Some(fl!("error-file-picker", err = e.to_string()))); - return; - } - } - } else { - match FileAccessToken::deserialize(¤t_path) { - Ok(t) => t, - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); - return; - } - } - }; - - let format = detect_format(&token); - let mut writer = match token.open_write() { - Ok(w) => w, - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); - return; - } - }; - - let res = match format { - DocumentFormat::Xlsx => loki_ooxml::xlsx::export::XlsxExport::export(&wb, &mut *writer) - .map_err(|e| e.to_string()), - DocumentFormat::Ods => { - loki_odf::OdsExport::export(&wb, &mut *writer).map_err(|e| e.to_string()) - } - DocumentFormat::Unsupported(ext) => Err(format!("Unsupported format: .{ext}")), - }; - - match res { - Err(e) => { - save_message.set(Some(fl!("editor-save-error", reason = e))); - } - Ok(()) => { - let new_path = token.serialize(); - let new_title = display_title_from_path(&new_path); - - path_signal.set(new_path.clone()); - - if active_tab_idx > 0 { - let mut tabs_mut = tabs.write(); - if let Some(tab) = tabs_mut.get_mut(active_tab_idx - 1) { - tab.path = new_path.clone(); - tab.title = new_title; - tab.is_dirty = false; - } - } - - save_message.set(Some(fl!("editor-save-success"))); - navigator.push(Route::Editor { path: new_path }); - } - } - }); -} - /// Spreadsheet editor inner component. #[component] pub(super) fn EditorInner(path: String) -> Element { @@ -214,6 +126,9 @@ pub(super) fn EditorInner(path: String) -> Element { // Transient save status (success or error) shown as a dismissible banner. let mut save_message = use_signal(|| Option::::None); + // Ribbon chrome state (F6d): active tab + collapse are live signals. + let mut ribbon_tab = use_signal(|| 0_usize); + let mut ribbon_collapsed = use_signal(|| false); // ── Session restore at mount ───────────────────────────────────────────── // Navigating Editor → Home unmounts this component (different routes), so @@ -1097,19 +1012,21 @@ pub(super) fn EditorInner(path: String) -> Element { {main_content} // ── Ribbon (formatting controls) ────────────────────────────────── + // Only tabs with real content are listed (the loki-text + // convention) — the earlier Insert/Format/Review/View entries were + // dead affordances (audit F6d). Collapse is wired for real. AtRibbon { tabs: vec![ - RibbonTabDesc { label: fl!("ribbon-tab-home"), is_contextual: false, aria_label: None }, - RibbonTabDesc { label: fl!("ribbon-tab-insert"), is_contextual: false, aria_label: None }, - RibbonTabDesc { label: fl!("ribbon-tab-format"), is_contextual: false, aria_label: None }, - RibbonTabDesc { label: fl!("ribbon-tab-review"), is_contextual: false, aria_label: None }, - RibbonTabDesc { label: fl!("ribbon-tab-view"), is_contextual: false, aria_label: None }, + RibbonTabDesc { label: fl!("ribbon-tab-home"), is_contextual: false, aria_label: None }, ], - active_tab: 0, - collapsed: false, - on_toggle_collapse: move |_| {}, + active_tab: ribbon_tab(), + collapsed: ribbon_collapsed(), + on_toggle_collapse: move |_| { + let next = !*ribbon_collapsed.peek(); + ribbon_collapsed.set(next); + }, toggle_aria_label: fl!("ribbon-collapse-aria"), - on_tab_select: move |_idx| {}, + on_tab_select: move |idx| ribbon_tab.set(idx), tab_content: home_tab } diff --git a/loki-spreadsheet/src/routes/editor/editor_save.rs b/loki-spreadsheet/src/routes/editor/editor_save.rs new file mode 100644 index 00000000..bbe784ca --- /dev/null +++ b/loki-spreadsheet/src/routes/editor/editor_save.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Workbook save flow (XLSX/ODS export to the current or a picked token). +//! +//! Extracted from `editor_inner.rs` (an oversized file). + +use dioxus::prelude::*; +use loki_file_access::{FileAccessToken, FilePicker, SaveOptions}; +use loki_i18n::fl; + +use super::editor_load::{DocumentFormat, detect_format}; +use crate::routes::Route; +use crate::routes::dioxus_router::Navigator; +use crate::utils::display_title_from_path; + +/// Saves the workbook snapshot to the file target (or picks a target first if untitled) +pub(super) fn save_document( + _path_prop: String, + mut path_signal: Signal, + workbook_snap: Signal, + mut tabs: Signal>, + active_tab: Signal, + navigator: Navigator, + mut save_message: Signal>, +) { + let active_tab_idx = *active_tab.peek(); + let current_path = path_signal.peek().clone(); + let wb = workbook_snap.peek().clone(); + + spawn(async move { + save_message.set(None); // clear any previous status + let token = if crate::new_document::is_untitled(¤t_path) { + let picker = FilePicker::new(); + let opts = SaveOptions { + mime_type: Some( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string(), + ), + suggested_name: Some("Workbook.xlsx".to_string()), + }; + match picker.pick_file_to_save(opts).await { + Ok(Some(t)) => t, + Ok(None) => return, // user cancelled — not an error + Err(e) => { + save_message.set(Some(fl!("error-file-picker", err = e.to_string()))); + return; + } + } + } else { + match FileAccessToken::deserialize(¤t_path) { + Ok(t) => t, + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + return; + } + } + }; + + let format = detect_format(&token); + let mut writer = match token.open_write() { + Ok(w) => w, + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e.to_string()))); + return; + } + }; + + let res = match format { + DocumentFormat::Xlsx => loki_ooxml::xlsx::export::XlsxExport::export(&wb, &mut *writer) + .map_err(|e| e.to_string()), + DocumentFormat::Ods => { + loki_odf::OdsExport::export(&wb, &mut *writer).map_err(|e| e.to_string()) + } + DocumentFormat::Unsupported(ext) => Err(format!("Unsupported format: .{ext}")), + }; + + match res { + Err(e) => { + save_message.set(Some(fl!("editor-save-error", reason = e))); + } + Ok(()) => { + let new_path = token.serialize(); + let new_title = display_title_from_path(&new_path); + + path_signal.set(new_path.clone()); + + if active_tab_idx > 0 { + let mut tabs_mut = tabs.write(); + if let Some(tab) = tabs_mut.get_mut(active_tab_idx - 1) { + tab.path = new_path.clone(); + tab.title = new_title; + tab.is_dirty = false; + } + } + + save_message.set(Some(fl!("editor-save-success"))); + navigator.push(Route::Editor { path: new_path }); + } + } + }); +} diff --git a/loki-spreadsheet/src/routes/editor/mod.rs b/loki-spreadsheet/src/routes/editor/mod.rs index 7b59ac05..f1e0a984 100644 --- a/loki-spreadsheet/src/routes/editor/mod.rs +++ b/loki-spreadsheet/src/routes/editor/mod.rs @@ -9,6 +9,7 @@ mod editor_inner; mod editor_load; mod editor_mutate; mod editor_path_sync; +mod editor_save; mod editor_state; mod formula; #[cfg(test)] diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index 9904ed88..96d7450f 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -8,8 +8,8 @@ 1554 loki-odf/src/odt/reader/styles.rs 1494 loki-odf/src/odt/reader/document.rs 1209 loki-ooxml/src/docx/reader/document.rs -1130 loki-spreadsheet/src/routes/editor/editor_inner.rs 1073 loki-ooxml/src/docx/write/document.rs +1047 loki-spreadsheet/src/routes/editor/editor_inner.rs 978 loki-layout/src/resolve.rs 948 loki-vello/src/scene.rs 814 loki-text/src/routes/editor/editor_inner.rs From c0e0b4a233beb59bb46a04f3b75fb2bea8872cd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 00:53:30 +0000 Subject: [PATCH 25/27] Disable the ribbon Save button when the document is clean (4b.3 tail) The whole point of the undo-stack clean checkpoint (4b.3) was to know when there is nothing to save; the Save button was still hardcoded is_disabled: false. - New reactive is_dirty signal in EditorState, set by the existing dirty-tracking effect alongside the tab indicator (a peek-guard avoids a needless ribbon re-render when the value is unchanged). - Threaded into write_tab_content; the Save button now uses is_disabled = !is_dirty(). Untitled documents read as dirty (Save routes to Save As), so their button stays enabled; a clean titled document disables it. Ceiling: condensed the dirty-tracking + Save-As comments so editor_inner.rs stays under its baseline and editor_ribbon.rs stays at exactly 300. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- docs/deferred-features-plan-2026-07-04.md | 2 +- loki-text/src/routes/editor/editor_inner.rs | 22 +++++++++----------- loki-text/src/routes/editor/editor_ribbon.rs | 7 ++++--- loki-text/src/routes/editor/editor_state.rs | 6 ++++++ 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/deferred-features-plan-2026-07-04.md b/docs/deferred-features-plan-2026-07-04.md index 3e29a9f5..dc8796c7 100644 --- a/docs/deferred-features-plan-2026-07-04.md +++ b/docs/deferred-features-plan-2026-07-04.md @@ -118,7 +118,7 @@ TODOs (merged with whatever Phase 0 confirms from F1–F7). |---|---|---|---| | 4b.1 | `3b-3` | ✅ **Done 2026-07-05** — (1) Left/Right cross page boundaries (the prev/next-entry searches walk the whole layout; entering a table from above/below lands in its first/last cell). (2) New `editing/page_locate.rs`: `recompute_page_index` re-derives a position's page from the layout, picking the page whose content band shows the byte's line for page-spanning paragraphs; wired into every mutation caret placement (`set_collapsed_cursor` — split, merge, typing, selection removal) and after every navigation move, replacing the stale-page TODOs. 4 page-locate tests (incl. a split-fragment band test) + 4 cross-page nav tests; `navigation.rs` split (`navigation_find.rs`) dropped it from the ceiling baseline (34 → 33). **Remaining `3b-3` tail:** the double-Enter list-exit heuristic (`clear_para_props`). | M | | 4b.2 | `formatting` | ✅ **Done 2026-07-05** — the six toggles (bold/italic/underline/strikethrough/super/subscript) apply across every paragraph of a same-container multi-block selection: new `editor_format_range.rs` `resolve_format_ranges` yields per-paragraph `(BlockPath, start, end)` ranges (first-paragraph tail, full middles, last-paragraph head; text-less blocks like tables inside the range are skipped, their cells untouched); the state at the selection start decides apply-vs-clear and the whole selection is made uniform (Word behaviour). Cross-container selections keep the clamp-to-focus rule. 8 tests incl. an end-to-end double-toggle. | M | -| 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Remaining tail:** Spec 01's typed `SaveError` residual; disabling the ribbon Save button when clean (needs the dirty flag threaded into the ribbon). | M | +| 4b.3 | `undo-dirty` | ✅ **Done 2026-07-05** — undo-stack clean checkpoint: `editing/saved_state.rs` `SavedStateHandle` mirrors the undo depth via the loro `on_push`/`on_pop` hooks (fresh edits arrive with `Some(event)`, undo/redo replays with `None`), save records the clean depth (+ `record_new_checkpoint()`), and the dirty indicator clears when undo/redo returns the stack to it. Editing below the save point marks it permanently unreachable (classic clean-index semantics). Tracker rides with the `UndoManager` through tab-switch stash and the post-save compaction swap. 6 integration tests against a real loro `UndoManager` (`saved_state_tests.rs`). Save As also moved to `editor_save_callbacks.rs`, ratcheting `editor_inner.rs` 870 → 833. **Ribbon Save disables when clean ✅ 2026-07-05:** the dirty state is now a reactive `is_dirty` signal (set by the dirty-tracking effect) threaded into `write_tab_content`; the Save button's `is_disabled = !is_dirty()` (untitled reads as dirty, so Save→Save-As stays enabled). **Remaining tail:** Spec 01's typed `SaveError` residual. | M | | 4b.4 | `nested-nav` | ✅ **Done 2026-07-05** — paginated navigation is path-aware end-to-end: `find_para_data` matches `(block_index, path)` (index alone returned the first cell's entry for every table paragraph), the `get_text` closures take a `BlockPath` (grapheme moves inside cells previously read the root block's empty text), and Left/Right at a nested paragraph's edges cross to the sibling within the same cell / note body, clamping at the container's first/last block. Inline tests extracted to `navigation_tests.rs` (`#[path]` idiom, file ratcheted 593 → 367) + 6 nested-nav regression tests. Reflow navigation stays top-level-only (tables have no reflow editing data). | S | | 4b.5 | `rotated-cell-editing` | Editing data for rotated table cells (`flow.rs:1676`) — read-only today. **Note:** `flow.rs` is a top ceiling offender; split it (Phase 7) before or with this change. | M | | 4b.6 | F3c + F1 residual (audit §9) | **Dirty-close protection ✅ Done 2026-07-05:** closing a dirty tab now raises a confirmation dialog in **all three apps** (new `appthere_ui::AtConfirmDialog` overlay primitive — absolute backdrop + centred card, backdrop-cancel, 44 px touch targets; shells gain `pending_close` state + an extracted `close_tab` helper). **Tab-switch retention ✅ Done 2026-07-05 (F1 residual):** loki-presentation *and* loki-spreadsheet now stash the live editing state (presentation: doc + slide index + dirty; spreadsheet: CRDT + undo manager + grid snapshot + selection) into an app-level session map on tab switch / Editor→Home unmount and restore it on return — mirroring loki-text's `sessions.rs`; closing a tab drops the session. The presentation load path also gained the `(path, result)` stale-value guard the other apps already had. | S–M | diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index 878bb952..0c9b7842 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -107,6 +107,7 @@ pub(super) fn EditorInner(path: String) -> Element { is_style_picker_open, editing_style_draft, mut zoom_percent, + mut is_dirty, save_message, save_request, mut active_ribbon_tab, @@ -460,18 +461,19 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Unsaved-changes (dirty) tracking ───────────────────────────────────── // - // The tab shows a dirty indicator when the live document generation differs - // from the clean baseline captured at load/save — unless the undo-stack - // clean checkpoint says the document is back at its saved state (undoing - // to the save point clears dirty; plan 4b.3). Untitled documents are - // always dirty until the first Save As. Runs whenever the cursor's - // mirrored generation, the path, or the baseline changes. + // Dirty = the live generation differs from the clean baseline AND the + // undo-stack clean checkpoint disagrees (undoing to the save point clears + // dirty; plan 4b.3); untitled docs are always dirty until the first Save + // As. Drives the tab indicator and the `is_dirty` signal (ribbon Save). use_effect(move || { let live_gen = cursor_state.read().document_generation; let path = path_signal(); let base = baseline_gen(); let undo_clean = saved_state.read().is_clean(); let dirty = is_untitled(&path) || (live_gen != base && !undo_clean); + if *is_dirty.peek() != dirty { + is_dirty.set(dirty); // guard avoids a needless ribbon re-render + } let mut t = tabs.write(); if let Some(tab) = t.iter_mut().find(|tab| tab.path == path) && tab.is_dirty != dirty @@ -480,18 +482,13 @@ pub(super) fn EditorInner(path: String) -> Element { } }); - // ── Save As ────────────────────────────────────────────────────────────── - // Extracted flow (editor_save_callbacks): picks a destination, exports - // DOCX, repoints the tab, records recents, and navigates to the new path. + // ── Save As / Save as Template (extracted flows: editor_save_callbacks) ── let save_as = super::editor_save_callbacks::use_save_as_callback( Arc::clone(&doc_state), save_message, baseline_gen, path_signal, ); - - // ── Save as Template (.dotx) ─────────────────────────────────────────────── - // Self-contained save flow — extracted to keep this file under its ceiling. let save_as_template = super::editor_save_callbacks::use_save_as_template_callback( Arc::clone(&doc_state), save_message, @@ -764,6 +761,7 @@ pub(super) fn EditorInner(path: String) -> Element { current_style_name, is_style_picker_open, save_request, + is_dirty, editing_style_draft, save_as, save_as_template, diff --git a/loki-text/src/routes/editor/editor_ribbon.rs b/loki-text/src/routes/editor/editor_ribbon.rs index 1e05e207..34c62c0e 100644 --- a/loki-text/src/routes/editor/editor_ribbon.rs +++ b/loki-text/src/routes/editor/editor_ribbon.rs @@ -3,8 +3,7 @@ //! Write tab ribbon content for the document editor (was "Home", Spec 04 D1). //! //! [`write_tab_content`] returns the `Element` passed to [`AtRibbon::tab_content`]. -//! Separating it here keeps `editor_inner.rs` under the 300-line ceiling and -//! makes ribbon content easy to extend independently. +//! Separating it here keeps `editor_inner.rs` under the 300-line ceiling. use std::sync::{Arc, Mutex}; @@ -52,6 +51,7 @@ pub(super) fn write_tab_content( current_style_name: String, mut is_style_picker_open: Signal, mut save_request: Signal, + is_dirty: Signal, mut editing_style_draft: Signal>, save_as: Callback<()>, save_as_template: Callback<()>, @@ -77,7 +77,8 @@ pub(super) fn write_tab_content( AtRibbonIconButton { aria_label: fl!("ribbon-save-aria"), is_active: false, - is_disabled: false, + // Disabled when clean (plan 4b.3); untitled reads as dirty. + is_disabled: !is_dirty(), on_click: move |_| { // Route through the shared save handler (the Ctrl+S effect // in `EditorInner`), which owns the untitled→Save-As diff --git a/loki-text/src/routes/editor/editor_state.rs b/loki-text/src/routes/editor/editor_state.rs index 2ee05f1f..cafc30b2 100644 --- a/loki-text/src/routes/editor/editor_state.rs +++ b/loki-text/src/routes/editor/editor_state.rs @@ -133,6 +133,11 @@ pub(super) struct EditorState { /// Paginated render zoom, in percent (100 = 1:1). Cycled by the status /// bar's zoom badge; scales page tiles + paint together (4c.5 / F6d). pub zoom_percent: Signal, + /// Whether the document has unsaved changes (mirror of the tab's dirty + /// indicator; plan 4b.3). Drives the ribbon Save button's disabled state — + /// a clean *titled* document has nothing to save. Untitled documents stay + /// dirty (Save routes to Save As), so their Save button stays enabled. + pub is_dirty: Signal, /// Last save result message (`None` = nothing to show). pub save_message: Signal>, /// Monotonic counter bumped by the Ctrl+S handler. `EditorInner` watches it @@ -200,6 +205,7 @@ pub(super) fn use_editor_state() -> EditorState { is_style_picker_open: use_signal(|| false), editing_style_draft: use_signal(|| None), zoom_percent: use_signal(|| 100_u32), + is_dirty: use_signal(|| false), save_message: use_signal(|| None), save_request: use_signal(|| 0_u32), active_ribbon_tab: use_signal(|| 0_usize), From 9276d0df4411fb74369b2a4867de5ff385d9bad7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 01:56:46 +0000 Subject: [PATCH 26/27] Fix code-review findings in the Phase 4 editor work Addresses the 15 confirmed findings from the high-effort review of the Phase 4 editor changes (10 reported + 5 cut-by-cap items). Correctness: - delete_selection_at: pre-validate endpoint byte offsets before any merge runs, so a stale offset rejects cleanly instead of leaving a half-applied mutation, and can no longer underflow the final delete. - Paginated zoom: thread the zoom factor into the window-relative drag/touch hit-testing and the onscroll page-index math, so drag-selection, long-press, and the status-bar page indicator are correct at zoom != 100% (previously only per-tile click was). - stash_outgoing (loki-text): guard the session stash on tab liveness (moved into the shared helper) so a discarded/close-repointed tab no longer resurrects its old state on reopen. - Recents delete (all three apps): drop the stashed DocSession when a file is deleted from Home, fixing a LoroDoc/layout leak and stale restore at the same token key. - Enter now replaces the active selection with a paragraph break (was splitting at the focus and keeping the selected text). - Nested navigation: Arrow Left/Right find cell/note siblings across a page break, and escape the container to the adjacent top-level block at its first/last block (Arrow keys could enter a table but not leave). - Hyperlinks apply across every paragraph of a multi-block selection, not just the first; the unused singular range helper is removed. - Undo/redo and mid-paragraph forward-Delete now recompute the cursor's page_index from the fresh layout. - has_selection compares the logical caret (ignoring page_index) so a page-spanning fragment no longer forms a phantom zero-width selection that swallows a Backspace/Delete. Cleanup/efficiency: - Word count: gate the full-document recount on a narrow generation memo (was re-walking on every cursor move), and count figure captions per the module contract. - Dirty tracking: peek before tabs.write() so a keystroke no longer re-renders the tab bar when nothing changed. - Add the missing line-2 copyright header to nine files. Splits (300-line ceiling): extract cursor tests, handle_enter_key, the canvas loading view, and the dirty-tracking effect into sibling modules; editor_canvas.rs and editor_inner.rs drop below their baselines (ratchet tightened). Tests added for stale-offset rejection, zoomed hit-testing, cross-page and cell-escape navigation, multi-paragraph hyperlinks, figure-caption counting, and the phantom-selection case. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- .../src/components/home_tab/recent_row.rs | 1 + loki-doc-model/src/loro_mutation/selection.rs | 23 +++ .../tests/loro_selection_delete_tests.rs | 38 +++++ loki-presentation/src/routes/home.rs | 4 +- loki-presentation/src/routes/home_util.rs | 15 +- loki-presentation/src/sessions.rs | 1 + .../src/routes/editor/editor_path_sync.rs | 1 + loki-spreadsheet/src/routes/home.rs | 4 +- loki-spreadsheet/src/routes/home_util.rs | 15 +- loki-spreadsheet/src/sessions.rs | 1 + loki-text/src/editing/cursor.rs | 135 ++++-------------- loki-text/src/editing/cursor_tests.rs | 124 ++++++++++++++++ loki-text/src/editing/hit_test.rs | 43 ++++-- loki-text/src/editing/hit_test_tests.rs | 79 ++++++++++ loki-text/src/editing/navigation.rs | 25 ++-- loki-text/src/editing/navigation_find.rs | 45 ++++-- loki-text/src/editing/navigation_tests.rs | 92 +++++++++++- loki-text/src/editing/word_count.rs | 16 ++- loki-text/src/editing/word_count_tests.rs | 18 ++- loki-text/src/routes/editor/editor_canvas.rs | 48 ++----- .../routes/editor/editor_canvas_loading.rs | 48 +++++++ loki-text/src/routes/editor/editor_dirty.rs | 54 +++++++ .../src/routes/editor/editor_format_range.rs | 13 +- loki-text/src/routes/editor/editor_inner.rs | 53 +++---- loki-text/src/routes/editor/editor_insert.rs | 28 ++-- .../src/routes/editor/editor_insert_tests.rs | 31 ++++ loki-text/src/routes/editor/editor_keydown.rs | 3 +- .../src/routes/editor/editor_keydown_ctrl.rs | 99 +++++-------- .../src/routes/editor/editor_keydown_enter.rs | 95 ++++++++++++ .../src/routes/editor/editor_keydown_text.rs | 9 +- .../src/routes/editor/editor_path_sync.rs | 15 +- loki-text/src/routes/editor/editor_pointer.rs | 17 ++- loki-text/src/routes/editor/mod.rs | 3 + loki-text/src/routes/home.rs | 8 +- loki-text/src/routes/home_util.rs | 15 +- scripts/file-ceiling-baseline.txt | 4 +- 36 files changed, 901 insertions(+), 322 deletions(-) create mode 100644 loki-text/src/editing/cursor_tests.rs create mode 100644 loki-text/src/routes/editor/editor_canvas_loading.rs create mode 100644 loki-text/src/routes/editor/editor_dirty.rs create mode 100644 loki-text/src/routes/editor/editor_keydown_enter.rs diff --git a/appthere-ui/src/components/home_tab/recent_row.rs b/appthere-ui/src/components/home_tab/recent_row.rs index b3d35852..7efb487e 100644 --- a/appthere-ui/src/components/home_tab/recent_row.rs +++ b/appthere-ui/src/components/home_tab/recent_row.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! One row of [`super::recent_files::AtRecentFileList`]: the document info //! button, the ⋮ menu toggle, and the inline context menu. diff --git a/loki-doc-model/src/loro_mutation/selection.rs b/loki-doc-model/src/loro_mutation/selection.rs index 664a8dab..ca69c550 100644 --- a/loki-doc-model/src/loro_mutation/selection.rs +++ b/loki-doc-model/src/loro_mutation/selection.rs @@ -44,6 +44,16 @@ fn with_leaf(path: &BlockPath, leaf: usize) -> BlockPath { p } +/// Validates that `byte` is a UTF-8 char boundary within the text of the block +/// at `path` (rejecting a stale offset before any mutation runs). +fn validate_offset(loro: &LoroDoc, path: &BlockPath, byte: usize) -> Result<(), MutationError> { + let text = text_for_path(loro, path)?.to_string(); + if byte > text.len() || !text.is_char_boundary(byte) { + return Err(MutationError::InvalidByteOffset { offset: byte }); + } + Ok(()) +} + /// Whether two paths address sibling blocks of one container: both top-level, /// or nested with identical root, identical non-leaf steps, and the same leaf /// cell / note. @@ -75,6 +85,9 @@ fn same_container(a: &BlockPath, b: &BlockPath) -> bool { /// - [`MutationError::TextNotFound`] — a block inside the range carries no /// editable text (e.g. a table or horizontal rule); nothing is mutated /// (the whole range is validated before the first mutation). +/// - [`MutationError::InvalidByteOffset`] — an endpoint offset is past the end +/// of its block's text or not on a char boundary (e.g. a stale offset from a +/// concurrent edit); nothing is mutated. /// - Any error the underlying primitives report. pub fn delete_selection_at( loro: &LoroDoc, @@ -94,6 +107,16 @@ pub fn delete_selection_at( let start_leaf = leaf_index(start_path); let end_leaf = leaf_index(end_path); + // Validate the byte offsets against the actual block text lengths BEFORE + // any mutation. Without this, a stale offset (e.g. a concurrent remote edit + // that shortened a paragraph) would only surface when the final + // `delete_text_at` runs — after every `merge_block_at` has already mutated + // the document, leaving it half-applied — and `join + end_byte - start_byte` + // could underflow `usize`. Validating start against its (full) block also + // guarantees `start_byte <= join`, so that subtraction cannot underflow. + validate_offset(loro, start_path, start_byte)?; + validate_offset(loro, end_path, end_byte)?; + // Single-block selection: one plain text deletion. if start_leaf == end_leaf { if end_byte > start_byte { diff --git a/loki-doc-model/tests/loro_selection_delete_tests.rs b/loki-doc-model/tests/loro_selection_delete_tests.rs index ae7c69e0..f61a037b 100644 --- a/loki-doc-model/tests/loro_selection_delete_tests.rs +++ b/loki-doc-model/tests/loro_selection_delete_tests.rs @@ -104,6 +104,44 @@ fn reversed_offsets_within_one_block_normalize() { assert_eq!(para_texts(&rebuilt, 0), vec!["Helld", "middle", "goodbye"]); } +#[test] +fn rejects_a_stale_end_offset_without_mutating() { + // A multi-block selection whose END offset is past the last block's text + // (e.g. a concurrent edit shortened "goodbye"): the pre-validation must + // reject it before any merge runs, leaving all three paragraphs intact. + let loro = document_to_loro(&three_para_doc()).unwrap(); + let (start, end) = (BlockPath::block(0), BlockPath::block(2)); + let err = delete_selection_at(&loro, (&start, 6), (&end, 99)); + assert!(matches!( + err, + Err(MutationError::InvalidByteOffset { offset: 99 }) + )); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!( + para_texts(&rebuilt, 0), + vec!["Hello world", "middle", "goodbye"] + ); +} + +#[test] +fn rejects_a_stale_start_offset_without_underflow() { + // A multi-block selection whose START offset is past the first block's text + // would make `join + end_byte - start_byte` underflow `usize` if it reached + // the delete; the pre-validation must reject it first, untouched. + let loro = document_to_loro(&three_para_doc()).unwrap(); + let (start, end) = (BlockPath::block(0), BlockPath::block(2)); + let err = delete_selection_at(&loro, (&start, 99), (&end, 4)); + assert!(matches!( + err, + Err(MutationError::InvalidByteOffset { offset: 99 }) + )); + let rebuilt = loro_to_document(&loro).unwrap(); + assert_eq!( + para_texts(&rebuilt, 0), + vec!["Hello world", "middle", "goodbye"] + ); +} + // ── Nested containers ─────────────────────────────────────────────────────── /// `[Para("intro"), Table]` — the table (global block 1) has one body row of diff --git a/loki-presentation/src/routes/home.rs b/loki-presentation/src/routes/home.rs index 49cdbb53..784985e8 100644 --- a/loki-presentation/src/routes/home.rs +++ b/loki-presentation/src/routes/home.rs @@ -12,6 +12,7 @@ use super::home_util::{close_tab_for_path, push_or_switch_tab, suggested_copy_na use crate::new_document::new_blank_tab; use crate::recent_documents::RecentDocuments; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -53,6 +54,7 @@ pub fn Home() -> Element { let tabs = use_context::>>(); let active_tab = use_context::>(); + let doc_sessions = use_context::>(); let mut recent_docs = use_context::>(); let pick_error: Signal> = use_signal(|| None); @@ -160,7 +162,7 @@ pub fn Home() -> Element { } } - close_tab_for_path(tabs, active_tab, &path); + close_tab_for_path(tabs, active_tab, doc_sessions, &path); recent_docs.write().remove(&path); recent_docs.read().save(); }; diff --git a/loki-presentation/src/routes/home_util.rs b/loki-presentation/src/routes/home_util.rs index e10c881a..e4b78808 100644 --- a/loki-presentation/src/routes/home_util.rs +++ b/loki-presentation/src/routes/home_util.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! Tab and file-token helpers for the Home route. //! @@ -7,6 +8,7 @@ use dioxus::prelude::*; use loki_file_access::FileAccessToken; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -33,10 +35,18 @@ pub(super) fn push_or_switch_tab( } /// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. +/// Home when the closed (or a now-shifted) tab was selected, and drop any +/// stashed editing session for that path. +/// +/// The session must be dropped here (not only in the shell's tab-close button): +/// deleting a presentation from the recents list while it is open, or with a +/// session stashed from an earlier tab switch, would otherwise leak the whole +/// `LoroDoc`/deck in the map — and a later file created at the same token key +/// would restore the deleted deck's content instead of loading fresh. pub(super) fn close_tab_for_path( mut tabs: Signal>, mut active_tab: Signal, + mut sessions: Signal, path: &str, ) { let removed = tabs.read().iter().position(|t| t.path == path); @@ -48,6 +58,9 @@ pub(super) fn close_tab_for_path( *active_tab.write() = 0; } } + // Drop the stashed session regardless of whether a tab was open — a session + // can outlive its tab (stashed on tab switch, then the tab closed). + sessions.write().remove(path); } /// Build a " Copy." filename from a token's display name. diff --git a/loki-presentation/src/sessions.rs b/loki-presentation/src/sessions.rs index 48b308b8..67a6ba56 100644 --- a/loki-presentation/src/sessions.rs +++ b/loki-presentation/src/sessions.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! In-memory editing sessions for inactive presentation tabs. //! diff --git a/loki-spreadsheet/src/routes/editor/editor_path_sync.rs b/loki-spreadsheet/src/routes/editor/editor_path_sync.rs index f5f5e38d..05aba4ed 100644 --- a/loki-spreadsheet/src/routes/editor/editor_path_sync.rs +++ b/loki-spreadsheet/src/routes/editor/editor_path_sync.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! Path-change detection and per-document state handover for //! [`super::editor_inner::EditorInner`]. diff --git a/loki-spreadsheet/src/routes/home.rs b/loki-spreadsheet/src/routes/home.rs index 9544e915..f0195cb2 100644 --- a/loki-spreadsheet/src/routes/home.rs +++ b/loki-spreadsheet/src/routes/home.rs @@ -12,6 +12,7 @@ use super::home_util::{close_tab_for_path, push_or_switch_tab, suggested_copy_na use crate::new_document::new_blank_tab; use crate::recent_documents::RecentDocuments; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -53,6 +54,7 @@ pub fn Home() -> Element { let tabs = use_context::>>(); let active_tab = use_context::>(); + let doc_sessions = use_context::>(); let mut recent_docs = use_context::>(); // Holds the last file-picker error message, if any. @@ -161,7 +163,7 @@ pub fn Home() -> Element { } } - close_tab_for_path(tabs, active_tab, &path); + close_tab_for_path(tabs, active_tab, doc_sessions, &path); recent_docs.write().remove(&path); recent_docs.read().save(); }; diff --git a/loki-spreadsheet/src/routes/home_util.rs b/loki-spreadsheet/src/routes/home_util.rs index 24c00073..e8e1e6fe 100644 --- a/loki-spreadsheet/src/routes/home_util.rs +++ b/loki-spreadsheet/src/routes/home_util.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! Tab and file-token helpers for the Home route. //! @@ -7,6 +8,7 @@ use dioxus::prelude::*; use loki_file_access::FileAccessToken; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -34,10 +36,18 @@ pub(super) fn push_or_switch_tab( } /// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. +/// Home when the closed (or a now-shifted) tab was selected, and drop any +/// stashed editing session for that path. +/// +/// The session must be dropped here (not only in the shell's tab-close button): +/// deleting a workbook from the recents list while it is open, or with a session +/// stashed from an earlier tab switch, would otherwise leak the whole +/// `LoroDoc`/workbook in the map — and a later file created at the same token +/// key would restore the deleted workbook's content instead of loading fresh. pub(super) fn close_tab_for_path( mut tabs: Signal>, mut active_tab: Signal, + mut sessions: Signal, path: &str, ) { let removed = tabs.read().iter().position(|t| t.path == path); @@ -49,6 +59,9 @@ pub(super) fn close_tab_for_path( *active_tab.write() = 0; } } + // Drop the stashed session regardless of whether a tab was open — a session + // can outlive its tab (stashed on tab switch, then the tab closed). + sessions.write().remove(path); } /// Build a " Copy." filename from a token's display name. diff --git a/loki-spreadsheet/src/sessions.rs b/loki-spreadsheet/src/sessions.rs index e70339dc..23f2fd84 100644 --- a/loki-spreadsheet/src/sessions.rs +++ b/loki-spreadsheet/src/sessions.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! In-memory editing sessions for inactive workbook tabs. //! diff --git a/loki-text/src/editing/cursor.rs b/loki-text/src/editing/cursor.rs index 9c6600bb..3432239c 100644 --- a/loki-text/src/editing/cursor.rs +++ b/loki-text/src/editing/cursor.rs @@ -41,6 +41,22 @@ impl DocumentPosition { } } + /// Whether two positions address the same caret location, ignoring + /// `page_index`. + /// + /// `page_index` is a display-only artifact: a paragraph flowing across a + /// page break has an entry on every page it touches, so the same logical + /// caret `(paragraph_index, byte_offset, path)` can carry different + /// `page_index` values depending on which fragment produced it. Selection + /// logic must compare on the logical caret so a "phantom" zero-width + /// selection differing only in `page_index` is not treated as a range. + #[must_use] + pub fn same_caret(&self, other: &DocumentPosition) -> bool { + self.paragraph_index == other.paragraph_index + && self.byte_offset == other.byte_offset + && self.path == other.path + } + /// The [`BlockPath`] addressing this position's paragraph for mutation. #[must_use] pub fn block_path(&self) -> BlockPath { @@ -126,9 +142,18 @@ impl CursorState { } } - /// Returns `true` when a range selection exists (anchor differs from focus). + /// Returns `true` when a range selection exists — the anchor and focus + /// address different caret locations. + /// + /// Compares on the logical caret (via [`DocumentPosition::same_caret`]), so + /// two positions that differ only in `page_index` (a page-spanning + /// paragraph fragment) do not register as a phantom zero-width selection + /// that would swallow the next Backspace/Delete. pub fn has_selection(&self) -> bool { - self.anchor.is_some() && self.focus.is_some() && self.anchor != self.focus + match (self.anchor.as_ref(), self.focus.as_ref()) { + (Some(a), Some(f)) => !a.same_caret(f), + _ => false, + } } /// The [`BlockPath`] of the focus position, if a cursor is placed. @@ -193,107 +218,5 @@ pub fn next_grapheme_boundary(text: &str, offset: usize) -> usize { // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn prev_grapheme_in_ascii_mid() { - // "hello": h(0) e(1) l(2) l(3) o(4) - assert_eq!(prev_grapheme_boundary("hello", 3), 2); - } - - #[test] - fn prev_grapheme_at_start() { - assert_eq!(prev_grapheme_boundary("hello", 0), 0); - } - - #[test] - fn next_grapheme_in_ascii_mid() { - assert_eq!(next_grapheme_boundary("hello", 2), 3); - } - - #[test] - fn next_grapheme_at_end() { - assert_eq!(next_grapheme_boundary("hello", 5), 5); - } - - #[test] - fn prev_grapheme_multibyte() { - // "héllo": h(0) é(1..3) l(3) l(4) o(5) - // é is U+00E9, encoded as 0xC3 0xA9 — 2 bytes. - // byte 3 is the start of 'l'; prev boundary should be 1 (start of é). - let s = "héllo"; - assert_eq!(prev_grapheme_boundary(s, 3), 1); - } - - #[test] - fn next_grapheme_emoji() { - // "a😀b": a(0) 😀(1..5) b(5) - // 😀 is U+1F600, encoded as 4 bytes. - // next boundary after offset 1 should be 5 (end of emoji / start of 'b'). - let s = "a\u{1F600}b"; - assert_eq!(next_grapheme_boundary(s, 1), 5); - } - - #[test] - fn top_level_position_block_path_is_flat() { - let pos = DocumentPosition::top_level(0, 3, 7); - assert_eq!(pos.block_path(), BlockPath::block(3)); - assert!(pos.path.is_empty()); - } - - #[test] - fn nested_position_block_path_carries_steps() { - let pos = DocumentPosition { - page_index: 0, - paragraph_index: 2, - byte_offset: 0, - path: vec![PathStep::Cell { cell: 1, block: 0 }], - }; - assert_eq!(pos.block_path(), BlockPath::in_cell(2, 1, 0)); - } - - #[test] - fn sibling_block_shifts_top_level_paragraph() { - let pos = DocumentPosition::top_level(0, 3, 7); - let next = pos.sibling_block(1, 0); - assert_eq!(next.paragraph_index, 4); - assert_eq!(next.byte_offset, 0); - assert!(next.path.is_empty()); - // Saturates at 0 rather than underflowing. - assert_eq!( - DocumentPosition::top_level(0, 0, 0) - .sibling_block(-1, 5) - .paragraph_index, - 0 - ); - } - - #[test] - fn sibling_block_shifts_nested_leaf_block_only() { - let pos = DocumentPosition { - page_index: 0, - paragraph_index: 2, - byte_offset: 9, - path: vec![PathStep::Cell { cell: 1, block: 0 }], - }; - let next = pos.sibling_block(1, 0); - // Root paragraph_index is untouched; the leaf block index advances. - assert_eq!(next.paragraph_index, 2); - assert_eq!(next.byte_offset, 0); - assert_eq!(next.path, vec![PathStep::Cell { cell: 1, block: 1 }]); - } - - #[test] - fn cursor_block_path_follows_focus() { - let mut cs = CursorState::new(); - assert_eq!(cs.block_path(), None); - cs.focus = Some(DocumentPosition { - page_index: 0, - paragraph_index: 5, - byte_offset: 0, - path: vec![PathStep::Note { note: 0, block: 0 }], - }); - assert_eq!(cs.block_path(), Some(BlockPath::in_note(5, 0, 0))); - } -} +#[path = "cursor_tests.rs"] +mod tests; diff --git a/loki-text/src/editing/cursor_tests.rs b/loki-text/src/editing/cursor_tests.rs new file mode 100644 index 00000000..6bd67d46 --- /dev/null +++ b/loki-text/src/editing/cursor_tests.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +use super::*; + +#[test] +fn prev_grapheme_in_ascii_mid() { + // "hello": h(0) e(1) l(2) l(3) o(4) + assert_eq!(prev_grapheme_boundary("hello", 3), 2); +} + +#[test] +fn prev_grapheme_at_start() { + assert_eq!(prev_grapheme_boundary("hello", 0), 0); +} + +#[test] +fn next_grapheme_in_ascii_mid() { + assert_eq!(next_grapheme_boundary("hello", 2), 3); +} + +#[test] +fn next_grapheme_at_end() { + assert_eq!(next_grapheme_boundary("hello", 5), 5); +} + +#[test] +fn prev_grapheme_multibyte() { + // "héllo": h(0) é(1..3) l(3) l(4) o(5) + // é is U+00E9, encoded as 0xC3 0xA9 — 2 bytes. + // byte 3 is the start of 'l'; prev boundary should be 1 (start of é). + let s = "héllo"; + assert_eq!(prev_grapheme_boundary(s, 3), 1); +} + +#[test] +fn next_grapheme_emoji() { + // "a😀b": a(0) 😀(1..5) b(5) + // 😀 is U+1F600, encoded as 4 bytes. + // next boundary after offset 1 should be 5 (end of emoji / start of 'b'). + let s = "a\u{1F600}b"; + assert_eq!(next_grapheme_boundary(s, 1), 5); +} + +#[test] +fn top_level_position_block_path_is_flat() { + let pos = DocumentPosition::top_level(0, 3, 7); + assert_eq!(pos.block_path(), BlockPath::block(3)); + assert!(pos.path.is_empty()); +} + +#[test] +fn nested_position_block_path_carries_steps() { + let pos = DocumentPosition { + page_index: 0, + paragraph_index: 2, + byte_offset: 0, + path: vec![PathStep::Cell { cell: 1, block: 0 }], + }; + assert_eq!(pos.block_path(), BlockPath::in_cell(2, 1, 0)); +} + +#[test] +fn sibling_block_shifts_top_level_paragraph() { + let pos = DocumentPosition::top_level(0, 3, 7); + let next = pos.sibling_block(1, 0); + assert_eq!(next.paragraph_index, 4); + assert_eq!(next.byte_offset, 0); + assert!(next.path.is_empty()); + // Saturates at 0 rather than underflowing. + assert_eq!( + DocumentPosition::top_level(0, 0, 0) + .sibling_block(-1, 5) + .paragraph_index, + 0 + ); +} + +#[test] +fn sibling_block_shifts_nested_leaf_block_only() { + let pos = DocumentPosition { + page_index: 0, + paragraph_index: 2, + byte_offset: 9, + path: vec![PathStep::Cell { cell: 1, block: 0 }], + }; + let next = pos.sibling_block(1, 0); + // Root paragraph_index is untouched; the leaf block index advances. + assert_eq!(next.paragraph_index, 2); + assert_eq!(next.byte_offset, 0); + assert_eq!(next.path, vec![PathStep::Cell { cell: 1, block: 1 }]); +} + +#[test] +fn has_selection_ignores_page_index_only_differences() { + // A page-spanning paragraph fragment can give the same logical caret a + // different page_index on each endpoint; that is NOT a range selection. + let mut cs = CursorState::new(); + cs.anchor = Some(DocumentPosition::top_level(0, 4, 7)); + cs.focus = Some(DocumentPosition::top_level(1, 4, 7)); + assert!( + !cs.has_selection(), + "endpoints differing only in page_index must not count as a selection" + ); + // A genuine byte difference is still a selection. + cs.focus = Some(DocumentPosition::top_level(1, 4, 8)); + assert!( + cs.has_selection(), + "a real byte-range must count as a selection" + ); +} + +#[test] +fn cursor_block_path_follows_focus() { + let mut cs = CursorState::new(); + assert_eq!(cs.block_path(), None); + cs.focus = Some(DocumentPosition { + page_index: 0, + paragraph_index: 5, + byte_offset: 0, + path: vec![PathStep::Note { note: 0, block: 0 }], + }); + assert_eq!(cs.block_path(), Some(BlockPath::in_note(5, 0, 0))); +} diff --git a/loki-text/src/editing/hit_test.rs b/loki-text/src/editing/hit_test.rs index d9197aad..6be1923a 100644 --- a/loki-text/src/editing/hit_test.rs +++ b/loki-text/src/editing/hit_test.rs @@ -88,8 +88,16 @@ pub fn reflow_hit_test_window( /// offset to Dioxus components. /// * `layout` — paginated layout produced with /// `LayoutOptions { preserve_for_editing: true }`. -/// * `page_width_px` / `page_height_px` — page canvas dimensions in CSS px. -/// * `page_gap_px` — vertical gap between page canvases in CSS px. +/// * `page_width_px` / `page_height_px` — **unzoomed** page canvas dimensions in +/// CSS px (as stored in `DocumentState`). The painted tiles are these scaled +/// by `zoom`; this function reapplies the scale internally. +/// * `page_gap_px` — vertical gap between page canvases in CSS px. This is a +/// fixed CSS margin that is **not** scaled by zoom (see +/// `loki_renderer::document_view`). +/// * `zoom` — the paginated render zoom (1.0 = 1:1). The caller must also pass a +/// `canvas_origin.0` centred on the **scaled** page width +/// (`centred_origin_x(page_width_px * zoom)`), since the tiles are centred at +/// their painted width. // All arguments are semantically distinct; grouping into a struct would reduce clarity at call sites. #[allow(clippy::too_many_arguments)] pub fn hit_test_document( @@ -101,7 +109,10 @@ pub fn hit_test_document( _page_width_px: f32, page_height_px: f32, page_gap_px: f32, + zoom: f32, ) -> Option { + let zoom = if zoom > 0.0 { zoom } else { 1.0 }; + // ── 1. Canvas-local coordinates in CSS pixels ───────────────────────────── let canvas_x_px = client_x - canvas_origin.0; let canvas_y_px = client_y - canvas_origin.1 + scroll_offset; @@ -110,26 +121,28 @@ pub fn hit_test_document( return None; } - // ── 2. Convert to layout points (1 pt = 96/72 CSS px) ──────────────────── - let canvas_x = canvas_x_px * PX_TO_PT; - let canvas_y = canvas_y_px * PX_TO_PT; - - let page_height_pt = page_height_px * PX_TO_PT; - let page_gap_pt = page_gap_px * PX_TO_PT; - - // ── 3. Determine which page was clicked ─────────────────────────────────── - let page_and_gap = page_height_pt + page_gap_pt; - if page_and_gap <= 0.0 { + // ── 2. Locate the page in scaled CSS px ────────────────────────────────── + // Tiles are painted at `zoom` scale; the inter-page gap is a fixed CSS + // margin that is not scaled. Work the page stride in CSS px, then convert + // the in-page offset to layout points, dividing the zoom back out. + let page_h_scaled = page_height_px * zoom; + let slot_px = page_h_scaled + page_gap_px; + if slot_px <= 0.0 { return None; } - let page_index = (canvas_y / page_and_gap) as usize; - let y_in_page = canvas_y - (page_index as f32 * page_and_gap); + let page_index = (canvas_y_px / slot_px) as usize; + let y_in_page_px = canvas_y_px - (page_index as f32 * slot_px); // Reject clicks in the gap between pages. - if y_in_page > page_height_pt || y_in_page < 0.0 { + if y_in_page_px > page_h_scaled || y_in_page_px < 0.0 { return None; } + // ── 3. In-page CSS px → layout points (1 pt = 96/72 CSS px, ÷ zoom) ─────── + let px_to_pt = PX_TO_PT / zoom; + let canvas_x = canvas_x_px * px_to_pt; + let y_in_page = y_in_page_px * px_to_pt; + hit_test_page(page_index, canvas_x, y_in_page, layout) } diff --git a/loki-text/src/editing/hit_test_tests.rs b/loki-text/src/editing/hit_test_tests.rs index 7e03294f..aca199ad 100644 --- a/loki-text/src/editing/hit_test_tests.rs +++ b/loki-text/src/editing/hit_test_tests.rs @@ -108,6 +108,7 @@ fn click_at_content_origin_returns_page0_para0_offset0() { page_w_px, page_h_px, pt_to_px(24.0), // page_gap_px + 1.0, // zoom ); let pos = result.expect("click at content origin should hit para 0"); assert_eq!(pos.page_index, 0); @@ -132,6 +133,7 @@ fn click_below_all_content_returns_none() { page_w_px, page_h_px, pt_to_px(24.0), + 1.0, // zoom ); assert!( result.is_none(), @@ -170,6 +172,7 @@ fn click_on_page2_returns_page_index_1() { page_w_px, page_h_px, page_gap_px, + 1.0, // zoom ); let pos = result.expect("click on page 2 should succeed"); assert_eq!(pos.page_index, 1, "should land on page 1 (0-based)"); @@ -229,6 +232,7 @@ fn scroll_offset_corrects_page2_click() { page_w_px, page_h_px, page_gap_px, + 1.0, // zoom ); let pos = result.expect("correct scroll_offset must resolve page 2 click"); assert_eq!( @@ -272,6 +276,7 @@ fn missing_scroll_offset_misses_page2_click() { page_w_px, page_h_px, page_gap_px, + 1.0, // zoom ); // Without scroll_offset, click_y maps to page 0 content (near top), // so result is either page 0 or None — never page 1. @@ -283,6 +288,80 @@ fn missing_scroll_offset_misses_page2_click() { } } +/// At zoom ≠ 1.0 the painted tiles are scaled, so a window-relative click must +/// be de-scaled before mapping to layout points. A click at the content origin +/// scaled by the zoom must still resolve to para 0, byte 0. +#[test] +fn zoomed_click_at_content_origin_resolves_para0() { + let layout = make_test_layout(); + let page = &layout.pages[0]; + let page_w_px = pt_to_px(page.page_size.width); + let page_h_px = pt_to_px(page.page_size.height); + let zoom = 2.0; + // The painted content origin is the (unzoomed) margin offset scaled by zoom. + let click_x = pt_to_px(page.margins.left) * zoom; + let click_y = pt_to_px(page.margins.top) * zoom; + + let result = hit_test_document( + click_x, + click_y, + canvas_origin_for_test(), + 0.0, + &layout, + page_w_px, + page_h_px, + pt_to_px(24.0), + zoom, + ); + let pos = result.expect("zoomed click at content origin should hit para 0"); + assert_eq!(pos.page_index, 0); + assert_eq!(pos.paragraph_index, 0); + assert_eq!(pos.byte_offset, 0, "top-left click should land at byte 0"); +} + +/// The page stride is `page_height × zoom + gap` (the gap is a fixed CSS margin, +/// unscaled). A zoomed click at page 2's content origin must resolve to page +/// index 1 — the pre-fix code divided by an unzoomed stride and over-counted. +#[test] +fn zoomed_click_on_page2_resolves_page_index_1() { + let layout = { + let single = make_test_layout(); + let page0 = single.pages[0].clone(); + let mut page1 = (*page0).clone(); + page1.page_number = 2; + PaginatedLayout { + page_size: single.page_size, + pages: vec![page0, Arc::new(page1)], + } + }; + let page = &layout.pages[1]; + let page_h_px = pt_to_px(layout.page_size.height); + let page_w_px = pt_to_px(layout.page_size.width); + let page_gap_px = pt_to_px(24.0); + let zoom = 2.0; + // Page 1's top is one scaled page height + one (unscaled) gap down; its + // content origin adds the scaled top margin. + let click_x = pt_to_px(page.margins.left) * zoom; + let click_y = page_h_px * zoom + page_gap_px + pt_to_px(page.margins.top) * zoom; + + let result = hit_test_document( + click_x, + click_y, + canvas_origin_for_test(), + 0.0, + &layout, + page_w_px, + page_h_px, + page_gap_px, + zoom, + ); + let pos = result.expect("zoomed click on page 2 should succeed"); + assert_eq!( + pos.page_index, 1, + "should land on page 1 (0-based) at zoom 2×" + ); +} + // ── Reflow (continuous) hit-testing ─────────────────────────────────────── /// One reflow paragraph laid out at the given canvas origin. diff --git a/loki-text/src/editing/navigation.rs b/loki-text/src/editing/navigation.rs index 69a9357d..57f41599 100644 --- a/loki-text/src/editing/navigation.rs +++ b/loki-text/src/editing/navigation.rs @@ -60,10 +60,14 @@ pub fn navigate_left( }); } - // At start of a nested paragraph — move to the end of the previous - // sibling within the same cell / note body (clamped at the first block). - if !focus.path.is_empty() { - let sibling = nested_sibling(focus, layout, -1)?; + // At start of a nested paragraph — move to the end of the previous sibling + // within the same cell / note body. When there is no previous sibling (the + // container's first block), fall through to the top-level prev-block search: + // the container is addressed by its root `paragraph_index`, so that search + // finds the block preceding it and the caret escapes the cell / note body. + if !focus.path.is_empty() + && let Some(sibling) = nested_sibling(focus, layout, -1) + { let prev_text = get_text(&sibling.block_path()); return Some(DocumentPosition { byte_offset: prev_text.len(), @@ -111,10 +115,15 @@ pub fn navigate_right( }); } - // At the end of a nested paragraph — move to the start of the next - // sibling within the same cell / note body (clamped at the last block). - if !focus.path.is_empty() { - return nested_sibling(focus, layout, 1); + // At the end of a nested paragraph — move to the start of the next sibling + // within the same cell / note body. When there is no next sibling (the + // container's last block), fall through to the top-level next-block search: + // the container is addressed by its root `paragraph_index`, so that search + // finds the block following it and the caret escapes the cell / note body. + if !focus.path.is_empty() + && let Some(sibling) = nested_sibling(focus, layout, 1) + { + return Some(sibling); } // At end of paragraph — move to the start of the next block, crossing diff --git a/loki-text/src/editing/navigation_find.rs b/loki-text/src/editing/navigation_find.rs index 9561793b..23eb0adb 100644 --- a/loki-text/src/editing/navigation_find.rs +++ b/loki-text/src/editing/navigation_find.rs @@ -43,10 +43,34 @@ pub(super) fn nested_leaf(focus: &DocumentPosition) -> Option { } } +/// The page index of a nested paragraph entry, searching **every** page. +/// +/// A table cell's blocks can flow across a page break, so the sibling of the +/// focus need not live on the focus's page; a page-local search would miss it +/// and strand the caret. Returns the first page whose editing data holds the +/// `(block_index, path)` entry. +fn nested_para_page( + layout: &PaginatedLayout, + block_index: usize, + path: &[PathStep], +) -> Option { + layout.pages.iter().position(|page| { + page.editing_data.as_ref().is_some_and(|ed| { + ed.paragraphs + .iter() + .any(|p| p.block_index == block_index && p.path == path) + }) + }) +} + /// The sibling of a nested `focus` shifted by `delta` blocks within its -/// container, if it exists in the page's layout. Clamps at the container's -/// first/last block by returning `None` — crossing out of a cell or note body -/// into the surrounding document is a separate feature. +/// container, if it exists in the layout. Clamps at the container's first/last +/// block by returning `None` — crossing out of a cell or note body into the +/// surrounding document is handled by the caller's top-level fallback. +/// +/// The sibling may have flowed onto a different page than the focus (a cell +/// split across a page break), so its `page_index` is re-derived from wherever +/// its entry actually appears rather than inherited from the focus. pub(super) fn nested_sibling( focus: &DocumentPosition, layout: &PaginatedLayout, @@ -55,14 +79,13 @@ pub(super) fn nested_sibling( let leaf = nested_leaf(focus)?; leaf.checked_add_signed(delta)?; // clamp at the container start let sibling = focus.sibling_block(delta, 0); - // Only cross when the sibling paragraph actually exists in the layout. - find_para_data( - layout, - sibling.page_index, - sibling.paragraph_index, - &sibling.path, - )?; - Some(sibling) + // Only cross when the sibling paragraph actually exists somewhere in the + // layout, and adopt the page it was laid out on. + let page = nested_para_page(layout, sibling.paragraph_index, &sibling.path)?; + Some(DocumentPosition { + page_index: page, + ..sibling + }) } /// Find the paragraph entry immediately preceding `block_index` in document order. diff --git a/loki-text/src/editing/navigation_tests.rs b/loki-text/src/editing/navigation_tests.rs index 974c3881..ee114fad 100644 --- a/loki-text/src/editing/navigation_tests.rs +++ b/loki-text/src/editing/navigation_tests.rs @@ -355,17 +355,22 @@ fn navigate_left_crosses_to_the_previous_cell_sibling() { } #[test] -fn navigate_left_clamps_at_the_first_block_of_a_cell() { +fn navigate_left_escapes_a_cell_at_its_first_block() { let layout = nested_cell_layout(); - // Start of "alpha": must NOT teleport to the top-level "intro" paragraph. - assert_eq!(navigate_left(&cell_pos(0, 0), &layout, nested_text), None); + // Start of "alpha" (first block of the cell): escape the table to the end + // of the preceding top-level "intro" paragraph (block 0). Arrow keys must + // be able to leave a table, not just enter it. + let result = navigate_left(&cell_pos(0, 0), &layout, nested_text); + assert_eq!(result, Some(DocumentPosition::top_level(0, 0, 5))); } #[test] -fn navigate_right_clamps_at_the_last_block_of_a_cell() { +fn navigate_right_escapes_a_cell_at_its_last_block() { let layout = nested_cell_layout(); - // End of "beta": must NOT teleport to the top-level "outro" paragraph. - assert_eq!(navigate_right(&cell_pos(1, 4), &layout, nested_text), None); + // End of "beta" (last block of the cell): escape the table to the start of + // the following top-level "outro" paragraph (block 2). + let result = navigate_right(&cell_pos(1, 4), &layout, nested_text); + assert_eq!(result, Some(DocumentPosition::top_level(0, 2, 0))); } #[test] @@ -480,3 +485,78 @@ fn navigate_right_at_document_end_still_returns_none() { let focus = DocumentPosition::top_level(1, 1, "second".len()); assert_eq!(navigate_right(&focus, &layout, two_page_text), None); } + +// ── Cross-page nested sibling (cell split across a page break) ──────────────── + +/// A table (block 1) whose single cell has two paragraphs that flow across a +/// page break: "alpha" on page 0, "beta" on page 1. +fn cell_split_across_pages() -> PaginatedLayout { + let mut resources = FontResources::new(); + let page_size = LayoutSize::new(595.0, 842.0); + let margins = LayoutInsets { + top: 72.0, + right: 72.0, + bottom: 72.0, + left: 72.0, + }; + let mk_page = |number: usize, paras: Vec| LayoutPage { + page_number: number, + page_size, + margins, + content_items: vec![], + header_items: vec![], + footer_items: vec![], + comment_items: vec![], + header_height: 0.0, + footer_height: 0.0, + editing_data: Some(PageEditingData { paragraphs: paras }), + }; + let alpha = layout_para(&mut resources, "alpha"); + let beta = layout_para(&mut resources, "beta"); + PaginatedLayout { + page_size, + pages: vec![ + Arc::new(mk_page( + 1, + vec![PageParagraphData { + block_index: 1, + path: vec![PathStep::Cell { cell: 0, block: 0 }], + layout: alpha, + origin: (0.0, 0.0), + }], + )), + Arc::new(mk_page( + 2, + vec![PageParagraphData { + block_index: 1, + path: vec![PathStep::Cell { cell: 0, block: 1 }], + layout: beta, + origin: (0.0, 0.0), + }], + )), + ], + } +} + +#[test] +fn navigate_right_crosses_to_a_cell_sibling_on_the_next_page() { + let layout = cell_split_across_pages(); + // End of "alpha" (page 0, cell block 0) → start of "beta", which was laid + // out on page 1. The result must adopt page_index 1, not stay on page 0. + let focus = DocumentPosition { + page_index: 0, + paragraph_index: 1, + byte_offset: 5, + path: vec![PathStep::Cell { cell: 0, block: 0 }], + }; + let result = navigate_right(&focus, &layout, |_| "alpha".to_string()); + assert_eq!( + result, + Some(DocumentPosition { + page_index: 1, + paragraph_index: 1, + byte_offset: 0, + path: vec![PathStep::Cell { cell: 0, block: 1 }], + }) + ); +} diff --git a/loki-text/src/editing/word_count.rs b/loki-text/src/editing/word_count.rs index 9df6481d..2110f764 100644 --- a/loki-text/src/editing/word_count.rs +++ b/loki-text/src/editing/word_count.rs @@ -105,7 +105,12 @@ fn count_blocks(blocks: &[Block], counter: &mut Counter) { } } } - Block::Figure(_, _, content) => count_blocks(content, counter), + Block::Figure(_, caption, content) => { + // The module contract counts figure captions; `caption.full` + // holds the caption's block content. + count_blocks(&caption.full, counter); + count_blocks(content, counter); + } // Generated or non-text content (and, `#[non_exhaustive]`, any // future block kind until it is classified) contributes nothing. _ => {} @@ -147,8 +152,15 @@ pub fn use_word_count_label( doc_state: Arc>, cursor_state: Signal, ) -> Memo { + // Narrow the change signal. Reading `cursor_state` directly in the count + // memo would subscribe it to *every* CursorState write — each cursor move, + // click, and drag update — re-running the full-document walk on the UI path + // even when the document did not change. This cheap intermediate memo reads + // the generation on every write, but its `u64` output only changes on a real + // mutation, so the expensive count below is gated on that. + let generation = use_memo(move || cursor_state.read().document_generation); use_memo(move || { - let _generation = cursor_state.read().document_generation; + let _generation = generation(); let count = doc_state .lock() .ok() diff --git a/loki-text/src/editing/word_count_tests.rs b/loki-text/src/editing/word_count_tests.rs index a6679420..0043c0b7 100644 --- a/loki-text/src/editing/word_count_tests.rs +++ b/loki-text/src/editing/word_count_tests.rs @@ -2,7 +2,7 @@ // Copyright 2026 AppThere Loki contributors use loki_doc_model::content::attr::NodeAttr; -use loki_doc_model::content::block::Block; +use loki_doc_model::content::block::{Block, Caption}; use loki_doc_model::content::inline::{Inline, NoteKind}; use loki_doc_model::content::table::core::{Table, TableBody, TableCaption, TableFoot, TableHead}; use loki_doc_model::content::table::row::{Cell, Row}; @@ -103,6 +103,22 @@ fn footnote_bodies_are_excluded() { assert_eq!(count_words(&doc), 2); } +#[test] +fn figure_caption_and_content_are_both_counted() { + // The module contract counts figure captions as well as figure content. + let caption = Caption { + short: None, + full: vec![Block::Para(vec![Inline::Str("caption words here".into())])], + }; + let doc = doc_with(vec![Block::Figure( + NodeAttr::default(), + caption, + vec![Block::Para(vec![Inline::Str("figure body".into())])], + )]); + // "caption words here" (3) + "figure body" (2) = 5. + assert_eq!(count_words(&doc), 5); +} + #[test] fn lists_and_headings_are_counted() { let doc = doc_with(vec![ diff --git a/loki-text/src/routes/editor/editor_canvas.rs b/loki-text/src/routes/editor/editor_canvas.rs index 8c341b5e..94228bbd 100644 --- a/loki-text/src/routes/editor/editor_canvas.rs +++ b/loki-text/src/routes/editor/editor_canvas.rs @@ -36,9 +36,9 @@ use dioxus::prelude::*; use loki_app_shell::spell::SpellService; use loki_doc_model::document::Document; use loki_doc_model::loro_bridge::derive_loro_cursor; -use loki_i18n::fl; use loki_renderer::{DocumentView, RendererCursorPos, TileContext, ViewMode}; +use super::editor_canvas_loading::loading_view; use super::editor_error_view::EditorErrorView; use super::editor_keydown::make_keydown_handler; use super::editor_pointer::{ @@ -59,43 +59,6 @@ use crate::error::LoadError; /// the real height. const DEFAULT_VIEWPORT_HEIGHT_PX: f64 = 800.0; -/// Blank page placeholder shown while a document is being opened. -/// -/// Renders immediately when the editor tab mounts (before the async load -/// resolves), so the user sees a page-shaped surface with an "opening" label -/// instead of an empty canvas while the file is read, imported, and laid out. -fn loading_view() -> Element { - rsx! { - div { - style: format!( - "display: flex; flex: 1; align-items: flex-start; \ - justify-content: center; width: 100%; padding-top: {gap}px;", - gap = tokens::SPACE_6, - ), - div { - style: format!( - "width: {w}px; height: {h}px; flex-shrink: 0; background: {page}; \ - border: 1px solid {border}; border-radius: 2px; display: flex; \ - align-items: center; justify-content: center;", - w = tokens::PAGE_WIDTH_PX, - h = tokens::PAGE_HEIGHT_PX, - page = tokens::CANVAS_PAGE_BG, - border = tokens::COLOR_BORDER_CHROME, - ), - span { - style: format!( - "font-family: {ff}; font-size: {fs}px; color: {fg};", - ff = tokens::FONT_FAMILY_UI, - fs = tokens::FONT_SIZE_BODY, - fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, - ), - { fl!("editor-document-loading") } - } - } - } - } -} - /// Right-click handler body: resolves the word under the tile-local coordinates /// in `ctx` (accurate, via `element_coordinates` — no window-centring math), /// selects it, and opens the spelling menu anchored at the cursor. A no-op when @@ -256,7 +219,11 @@ pub(super) fn render_canvas_area( Ok(s) => (s.page_height_px, s.page_count), Err(_) => return, }; - let slot = page_h + page_gap_px; + // Tiles are painted at `zoom` scale (the inter-page gap is a + // fixed, unscaled CSS margin), so the page stride the scroll + // offset measures against is `page_h × zoom + gap`. + let zoom = zoom_percent() as f32 / 100.0; + let slot = page_h * zoom + page_gap_px; if slot <= 0.0 || count == 0 { return; } @@ -288,6 +255,7 @@ pub(super) fn render_canvas_area( cursor_state, page_gap_px, view_mode, + zoom_percent, ), onmouseup: move |_| { @@ -314,6 +282,7 @@ pub(super) fn render_canvas_area( page_gap_px, view_mode, scroll_metrics, + zoom_percent, ), ontouchend: make_touchend_handler( @@ -325,6 +294,7 @@ pub(super) fn render_canvas_area( page_gap_px, view_mode, scroll_metrics, + zoom_percent, ), onkeydown: make_keydown_handler( diff --git a/loki-text/src/routes/editor/editor_canvas_loading.rs b/loki-text/src/routes/editor/editor_canvas_loading.rs new file mode 100644 index 00000000..c6a1b41c --- /dev/null +++ b/loki-text/src/routes/editor/editor_canvas_loading.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Loading placeholder for the document canvas. +//! +//! Extracted from `editor_canvas.rs` to keep that file under the 300-line +//! ceiling. + +use appthere_ui::tokens; +use dioxus::prelude::*; +use loki_i18n::fl; + +/// Blank page placeholder shown while a document is being opened. +/// +/// Renders immediately when the editor tab mounts (before the async load +/// resolves), so the user sees a page-shaped surface with an "opening" label +/// instead of an empty canvas while the file is read, imported, and laid out. +pub(super) fn loading_view() -> Element { + rsx! { + div { + style: format!( + "display: flex; flex: 1; align-items: flex-start; \ + justify-content: center; width: 100%; padding-top: {gap}px;", + gap = tokens::SPACE_6, + ), + div { + style: format!( + "width: {w}px; height: {h}px; flex-shrink: 0; background: {page}; \ + border: 1px solid {border}; border-radius: 2px; display: flex; \ + align-items: center; justify-content: center;", + w = tokens::PAGE_WIDTH_PX, + h = tokens::PAGE_HEIGHT_PX, + page = tokens::CANVAS_PAGE_BG, + border = tokens::COLOR_BORDER_CHROME, + ), + span { + style: format!( + "font-family: {ff}; font-size: {fs}px; color: {fg};", + ff = tokens::FONT_FAMILY_UI, + fs = tokens::FONT_SIZE_BODY, + fg = tokens::COLOR_TEXT_ON_CHROME_SECONDARY, + ), + { fl!("editor-document-loading") } + } + } + } + } +} diff --git a/loki-text/src/routes/editor/editor_dirty.rs b/loki-text/src/routes/editor/editor_dirty.rs new file mode 100644 index 00000000..465c043c --- /dev/null +++ b/loki-text/src/routes/editor/editor_dirty.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Unsaved-changes (dirty) tracking effect for the document editor. +//! +//! Extracted from `editor_inner.rs` to keep that file under the 300-line +//! ceiling. + +use dioxus::prelude::*; + +use crate::editing::cursor::CursorState; +use crate::editing::saved_state::SavedStateHandle; +use crate::new_document::is_untitled; +use crate::tabs::OpenTab; + +/// Wires the effect that keeps the `is_dirty` signal and the active tab's dirty +/// indicator in sync with the document's edit state. +/// +/// Dirty = the live generation differs from the clean baseline AND the +/// undo-stack clean checkpoint disagrees (undoing to the save point clears +/// dirty; plan 4b.3); untitled docs are always dirty until the first Save As. +pub(super) fn use_dirty_tracking( + cursor_state: Signal, + path_signal: Signal, + baseline_gen: Signal, + saved_state: Signal, + mut is_dirty: Signal, + mut tabs: Signal>, +) { + use_effect(move || { + let live_gen = cursor_state.read().document_generation; + let path = path_signal(); + let base = baseline_gen(); + let undo_clean = saved_state.read().is_clean(); + let dirty = is_untitled(&path) || (live_gen != base && !undo_clean); + if *is_dirty.peek() != dirty { + is_dirty.set(dirty); // guard avoids a needless ribbon re-render + } + // Only take a write guard when a tab's flag actually changes. A bare + // `tabs.write()` marks the shared tabs signal dirty and re-renders the + // whole tab bar on every keystroke, even when nothing changed — peek + // first and write only on a real transition. + let needs_update = tabs + .peek() + .iter() + .any(|tab| tab.path == path && tab.is_dirty != dirty); + if needs_update { + let mut t = tabs.write(); + if let Some(tab) = t.iter_mut().find(|tab| tab.path == path) { + tab.is_dirty = dirty; + } + } + }); +} diff --git a/loki-text/src/routes/editor/editor_format_range.rs b/loki-text/src/routes/editor/editor_format_range.rs index 1fe4ec21..a00a8497 100644 --- a/loki-text/src/routes/editor/editor_format_range.rs +++ b/loki-text/src/routes/editor/editor_format_range.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! Format-range resolution: maps the cursor/selection to the per-paragraph //! byte ranges a character-formatting action applies to (plan 4b.2). @@ -83,18 +84,6 @@ pub fn resolve_format_ranges( } } -/// Resolves a single format range — the first of [`resolve_format_ranges`]. -/// -/// Used by actions that need one contiguous range (e.g. hyperlink insertion); -/// for a multi-paragraph selection this is the selection's portion of its -/// first paragraph. -pub fn resolve_format_range( - loro: &LoroDoc, - cursor: &CursorState, -) -> Option<(BlockPath, usize, usize)> { - resolve_format_ranges(loro, cursor).into_iter().next() -} - /// The leaf block index of a position within its container (the leaf step's /// block index for nested positions, the paragraph index for top-level ones). fn leaf_index(pos: &DocumentPosition) -> usize { diff --git a/loki-text/src/routes/editor/editor_inner.rs b/loki-text/src/routes/editor/editor_inner.rs index 0c9b7842..7547b0b7 100644 --- a/loki-text/src/routes/editor/editor_inner.rs +++ b/loki-text/src/routes/editor/editor_inner.rs @@ -51,7 +51,6 @@ use super::editor_style::style_picker_panel; use super::editor_style_catalog::available_font_families; use super::editor_style_editor::style_editor_panel; use crate::error::LoadError; -use crate::new_document::is_untitled; use crate::sessions::DocSessions; use crate::tabs::OpenTab; use loki_app_shell::spell::SpellService; @@ -107,7 +106,7 @@ pub(super) fn EditorInner(path: String) -> Element { is_style_picker_open, editing_style_draft, mut zoom_percent, - mut is_dirty, + is_dirty, save_message, save_request, mut active_ribbon_tab, @@ -117,7 +116,7 @@ pub(super) fn EditorInner(path: String) -> Element { } = use_editor_state(); // ── Tab/recents context for Save As and the unsaved-changes indicator ──── - let mut tabs = use_context::>>(); + let tabs = use_context::>>(); // Spell-check service (provided at the app root). Drives the right-click // suggestions panel and the language picker. let spell_service = use_context::(); @@ -180,17 +179,15 @@ pub(super) fn EditorInner(path: String) -> Element { // ── Session stash at unmount ───────────────────────────────────────────── // - // Skipped when the tab was closed (Shell already dropped the session and - // re-stashing would resurrect discarded edits on reopen). + // `stash_outgoing` itself skips the stash when no tab still points at the + // path (the tab was closed, and Shell already dropped the session — re- + // stashing would resurrect discarded edits on reopen). { let doc_state_drop = Arc::clone(&doc_state); let tabs_at_drop = tabs; let mut sessions_at_drop = doc_sessions; use_drop(move || { let path = path_signal.peek().clone(); - if !tabs_at_drop.peek().iter().any(|t| t.path == path) { - return; - } let mut sig = PathSyncSignals { cursor_state, loro_doc, @@ -206,7 +203,13 @@ pub(super) fn EditorInner(path: String) -> Element { baseline_gen, saved_state, }; - stash_outgoing(&path, &doc_state_drop, &mut sessions_at_drop, &mut sig); + stash_outgoing( + &path, + &doc_state_drop, + tabs_at_drop, + &mut sessions_at_drop, + &mut sig, + ); }); } @@ -219,6 +222,7 @@ pub(super) fn EditorInner(path: String) -> Element { &path, &mut path_signal, &doc_state, + tabs, doc_sessions, &mut PathSyncSignals { cursor_state, @@ -459,28 +463,15 @@ pub(super) fn EditorInner(path: String) -> Element { let word_count_label = crate::editing::word_count::use_word_count_label(Arc::clone(&doc_state), cursor_state); - // ── Unsaved-changes (dirty) tracking ───────────────────────────────────── - // - // Dirty = the live generation differs from the clean baseline AND the - // undo-stack clean checkpoint disagrees (undoing to the save point clears - // dirty; plan 4b.3); untitled docs are always dirty until the first Save - // As. Drives the tab indicator and the `is_dirty` signal (ribbon Save). - use_effect(move || { - let live_gen = cursor_state.read().document_generation; - let path = path_signal(); - let base = baseline_gen(); - let undo_clean = saved_state.read().is_clean(); - let dirty = is_untitled(&path) || (live_gen != base && !undo_clean); - if *is_dirty.peek() != dirty { - is_dirty.set(dirty); // guard avoids a needless ribbon re-render - } - let mut t = tabs.write(); - if let Some(tab) = t.iter_mut().find(|tab| tab.path == path) - && tab.is_dirty != dirty - { - tab.is_dirty = dirty; - } - }); + // Unsaved-changes (dirty) tracking → tab indicator + ribbon Save state. + super::editor_dirty::use_dirty_tracking( + cursor_state, + path_signal, + baseline_gen, + saved_state, + is_dirty, + tabs, + ); // ── Save As / Save as Template (extracted flows: editor_save_callbacks) ── let save_as = super::editor_save_callbacks::use_save_as_callback( diff --git a/loki-text/src/routes/editor/editor_insert.rs b/loki-text/src/routes/editor/editor_insert.rs index cf860423..511af126 100644 --- a/loki-text/src/routes/editor/editor_insert.rs +++ b/loki-text/src/routes/editor/editor_insert.rs @@ -31,7 +31,7 @@ use loki_doc_model::{ }; use loro::{LoroDoc, LoroValue}; -use super::editor_format_range::resolve_format_range; +use super::editor_format_range::resolve_format_ranges; use crate::editing::cursor::CursorState; /// EMU per CSS pixel at 96 DPI (1 inch = 914 400 EMU = 96 px). Inserted images @@ -40,25 +40,37 @@ const EMU_PER_PX_96: u64 = 9525; /// Applies (or clears) a hyperlink over the selection or the word at the cursor. /// -/// An empty/whitespace-only `url` clears any existing link; otherwise the -/// resolved range is marked with [`MARK_LINK_URL`]. Returns `true` when a link -/// was applied, `false` when it was cleared or there was no resolvable range -/// (e.g. the cursor sits on whitespace with no selection). +/// An empty/whitespace-only `url` clears any existing link; otherwise every +/// resolved range is marked with [`MARK_LINK_URL`]. A multi-paragraph selection +/// resolves to one range per paragraph (like the character-formatting toggles), +/// so the whole selection is linked — not just its first paragraph. Returns +/// `true` when a link was applied, `false` when it was cleared or there was no +/// resolvable range (e.g. the cursor sits on whitespace with no selection). pub fn set_hyperlink( loro: &LoroDoc, cursor: &CursorState, url: &str, ) -> Result { - let Some((path, byte_start, byte_end)) = resolve_format_range(loro, cursor) else { + let ranges = resolve_format_ranges(loro, cursor); + if ranges.is_empty() { return Ok(false); - }; + } let trimmed = url.trim(); let value = if trimmed.is_empty() { LoroValue::Null } else { LoroValue::from(trimmed.to_string()) }; - mark_text_at(loro, &path, byte_start, byte_end, MARK_LINK_URL, value)?; + for (path, byte_start, byte_end) in &ranges { + mark_text_at( + loro, + path, + *byte_start, + *byte_end, + MARK_LINK_URL, + value.clone(), + )?; + } Ok(!trimmed.is_empty()) } diff --git a/loki-text/src/routes/editor/editor_insert_tests.rs b/loki-text/src/routes/editor/editor_insert_tests.rs index 6a6eccce..b907dd04 100644 --- a/loki-text/src/routes/editor/editor_insert_tests.rs +++ b/loki-text/src/routes/editor/editor_insert_tests.rs @@ -69,6 +69,37 @@ fn url_is_trimmed() { assert_eq!(link_at(&loro, 0).as_deref(), Some("https://trim.example")); } +#[test] +fn applies_link_across_a_multi_paragraph_selection() { + // Two paragraphs; a selection spanning both must link the tail of the + // first AND the head of the second (the bug linked only the first). + let mut doc = Document::new(); + doc.sections[0].blocks = vec![ + Block::Para(vec![Inline::Str("hello".into())]), + Block::Para(vec![Inline::Str("world".into())]), + ]; + let loro = document_to_loro(&doc).expect("document_to_loro"); + let cursor = CursorState { + loro_cursor: None, + anchor: Some(DocumentPosition::top_level(0, 0, 2)), + focus: Some(DocumentPosition::top_level(0, 1, 3)), + document_generation: 0, + }; + assert!(set_hyperlink(&loro, &cursor, "https://multi.example").unwrap()); + // First paragraph: linked from byte 2 onward. + let p0 = get_mark_at(&loro, 0, 3, MARK_LINK_URL).expect("get_mark_at p0"); + assert!( + matches!(p0, Some(LoroValue::String(_))), + "first para linked" + ); + // Second paragraph: also linked (byte 1 lies inside 0..3). + let p1 = get_mark_at(&loro, 1, 1, MARK_LINK_URL).expect("get_mark_at p1"); + assert!( + matches!(p1, Some(LoroValue::String(_))), + "second paragraph of the selection must be linked too" + ); +} + /// Encodes a `w`×`h` RGBA PNG into bytes for the image-insert tests. fn png_bytes(w: u32, h: u32) -> Vec { let img = image::RgbaImage::new(w, h); diff --git a/loki-text/src/routes/editor/editor_keydown.rs b/loki-text/src/routes/editor/editor_keydown.rs index 87e208a7..4fa38efc 100644 --- a/loki-text/src/routes/editor/editor_keydown.rs +++ b/loki-text/src/routes/editor/editor_keydown.rs @@ -11,7 +11,8 @@ use loki_doc_model::loro_mutation::{get_block_text, get_block_text_at}; use loki_renderer::ViewMode; use loki_renderer::render_layout::reflow_content_width_pt; -use super::editor_keydown_ctrl::{handle_ctrl_keys, handle_delete_key, handle_enter_key}; +use super::editor_keydown_ctrl::{handle_ctrl_keys, handle_delete_key}; +use super::editor_keydown_enter::handle_enter_key; use super::editor_keydown_text::{ SelectionRemoval, handle_backspace_key, handle_character_key, remove_selection, }; diff --git a/loki-text/src/routes/editor/editor_keydown_ctrl.rs b/loki-text/src/routes/editor/editor_keydown_ctrl.rs index 7df91a4d..778c86d6 100644 --- a/loki-text/src/routes/editor/editor_keydown_ctrl.rs +++ b/loki-text/src/routes/editor/editor_keydown_ctrl.rs @@ -10,12 +10,39 @@ use std::sync::{Arc, Mutex}; use dioxus::prelude::*; use keyboard_types::{Key, Modifiers}; use loki_doc_model::loro_mutation::{delete_text_at, get_block_text, get_block_text_at}; -use loki_doc_model::{StyleId, get_block_style_name, set_block_style, split_block_at}; use super::editor_formatting; use crate::editing::cursor::{CursorState, DocumentPosition, next_grapheme_boundary}; use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; +/// Re-derives the `page_index` of the cursor's anchor and focus from the +/// current paginated layout. Used after undo/redo, which can move (or remove) +/// the caret's paragraph across a page boundary; `recompute_page_index` leaves +/// a position unchanged when its paragraph is no longer in the layout. +fn recompute_cursor_pages( + doc_state: &Arc>, + mut cursor_state: Signal, +) { + let Some(layout) = doc_state + .lock() + .ok() + .and_then(|s| s.paginated_layout.clone()) + else { + return; + }; + let mut cs = cursor_state.write(); + if let Some(f) = cs.focus.clone() { + cs.focus = Some(crate::editing::page_locate::recompute_page_index( + &layout, &f, + )); + } + if let Some(a) = cs.anchor.clone() { + cs.anchor = Some(crate::editing::page_locate::recompute_page_index( + &layout, &a, + )); + } +} + /// Dispatches Ctrl/Meta/Super+key shortcuts. /// /// Handles select-all (`a`), bold (`b`), italic (`i`), underline (`u`), @@ -106,6 +133,8 @@ pub(super) fn handle_ctrl_keys( if let Some(ldoc) = ldoc_guard.as_ref() { apply_mutation_and_relayout(doc_state, ldoc); } + drop(ldoc_guard); + recompute_cursor_pages(doc_state, cursor_state); } "y" => { { @@ -118,6 +147,8 @@ pub(super) fn handle_ctrl_keys( if let Some(ldoc) = ldoc_guard.as_ref() { apply_mutation_and_relayout(doc_state, ldoc); } + drop(ldoc_guard); + recompute_cursor_pages(doc_state, cursor_state); } _ => {} } @@ -169,62 +200,6 @@ pub(super) fn handle_delete_key( }; apply_mutation_and_relayout(doc_state, ldoc); } - // Cursor stays at the same offset after forward delete. - post_mutation_sync( - doc_state, - loro_doc, - cursor_state, - undo_manager, - can_undo, - can_redo, - ); -} - -/// Handles the Enter key: splits the current paragraph at the cursor position. -pub(super) fn handle_enter_key( - focus: DocumentPosition, - loro_doc: Signal>, - doc_state: &Arc>, - cursor_state: Signal, - undo_manager: Signal>, - can_undo: Signal, - can_redo: Signal, -) { - let ldoc_guard = loro_doc.read(); - let Some(ldoc) = ldoc_guard.as_ref() else { - return; - }; - - let nested = !focus.path.is_empty(); - - // Resolve next_style_id for the current block's style before splitting. - // Style inheritance via next_style_id is a top-level concern (named styles - // address top-level paragraphs); nested splits keep the source block's type. - let next_style: Option = if nested { - None - } else { - let style_name = get_block_style_name(ldoc, focus.paragraph_index); - doc_state.lock().ok().and_then(|state| { - state - .document - .as_ref()? - .styles - .paragraph_styles - .get(&StyleId::new(&style_name)) - .and_then(|s| s.next_style_id.clone()) - }) - }; - - if split_block_at(ldoc, &focus.block_path(), focus.byte_offset).is_err() { - return; - } - - // Apply the next_style to the newly created block if one is defined. - if let Some(ref nstyle) = next_style { - let _ = set_block_style(ldoc, focus.paragraph_index + 1, nstyle); - } - - apply_mutation_and_relayout(doc_state, ldoc); post_mutation_sync( doc_state, loro_doc, @@ -233,12 +208,10 @@ pub(super) fn handle_enter_key( can_undo, can_redo, ); - // The split inserts the new block right after the source within the same - // container, so the caret moves to the next sibling block at offset 0 - // (its page_index is re-derived from the fresh layout — the new - // paragraph may start on the next page). - let new_pos = focus.sibling_block(1, 0); - super::editor_keydown_text::set_collapsed_cursor(doc_state, cursor_state, new_pos); + // The caret byte is unchanged, but forward-deleting text from a + // page-spanning paragraph can pull its later lines back a page — re-derive + // the caret's page_index from the fresh layout (plan 4b.1). + super::editor_keydown_text::set_collapsed_cursor(doc_state, cursor_state, focus); } /// Syncs cursor generation, `can_undo`, and `can_redo` after any document mutation. diff --git a/loki-text/src/routes/editor/editor_keydown_enter.rs b/loki-text/src/routes/editor/editor_keydown_enter.rs new file mode 100644 index 00000000..adab3b8f --- /dev/null +++ b/loki-text/src/routes/editor/editor_keydown_enter.rs @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors + +//! Enter-key handling for the document canvas: replace the active selection (if +//! any) with a paragraph break, then split the paragraph at the caret. +//! +//! Extracted from `editor_keydown_ctrl.rs` to keep that file under the 300-line +//! ceiling. Called by [`super::editor_keydown::make_keydown_handler`]. + +use std::sync::{Arc, Mutex}; + +use dioxus::prelude::*; +use loki_doc_model::{StyleId, get_block_style_name, set_block_style, split_block_at}; + +use super::editor_keydown_ctrl::post_mutation_sync; +use super::editor_keydown_text::{delete_selection_in_doc, set_collapsed_cursor}; +use crate::editing::cursor::{CursorState, DocumentPosition}; +use crate::editing::state::{DocumentState, apply_mutation_and_relayout}; + +/// Handles the Enter key: replaces the active selection (if any) with a +/// paragraph break — splitting the current paragraph at the cursor position. +/// +/// Like replace-typing (`handle_character_key`), an active selection is deleted +/// first and the split happens at the collapsed start, all in one relayout + +/// undo entry. A range the model rejects swallows the key. +pub(super) fn handle_enter_key( + focus: DocumentPosition, + loro_doc: Signal>, + doc_state: &Arc>, + cursor_state: Signal, + undo_manager: Signal>, + can_undo: Signal, + can_redo: Signal, +) { + let ldoc_guard = loro_doc.read(); + let Some(ldoc) = ldoc_guard.as_ref() else { + return; + }; + + // Replace the active selection: delete it in the CRDT first (batched into + // this same relayout/commit), then split at the collapsed start. + let focus = if cursor_state.read().has_selection() { + let Some(pos) = delete_selection_in_doc(ldoc, &cursor_state.read()) else { + return; // rejected range — swallow the key, do not split + }; + pos + } else { + focus + }; + + let nested = !focus.path.is_empty(); + + // Resolve next_style_id for the current block's style before splitting. + // Style inheritance via next_style_id is a top-level concern (named styles + // address top-level paragraphs); nested splits keep the source block's type. + let next_style: Option = if nested { + None + } else { + let style_name = get_block_style_name(ldoc, focus.paragraph_index); + doc_state.lock().ok().and_then(|state| { + state + .document + .as_ref()? + .styles + .paragraph_styles + .get(&StyleId::new(&style_name)) + .and_then(|s| s.next_style_id.clone()) + }) + }; + + if split_block_at(ldoc, &focus.block_path(), focus.byte_offset).is_err() { + return; + } + + // Apply the next_style to the newly created block if one is defined. + if let Some(ref nstyle) = next_style { + let _ = set_block_style(ldoc, focus.paragraph_index + 1, nstyle); + } + + apply_mutation_and_relayout(doc_state, ldoc); + post_mutation_sync( + doc_state, + loro_doc, + cursor_state, + undo_manager, + can_undo, + can_redo, + ); + // The split inserts the new block right after the source within the same + // container, so the caret moves to the next sibling block at offset 0 + // (its page_index is re-derived from the fresh layout — the new + // paragraph may start on the next page). + let new_pos = focus.sibling_block(1, 0); + set_collapsed_cursor(doc_state, cursor_state, new_pos); +} diff --git a/loki-text/src/routes/editor/editor_keydown_text.rs b/loki-text/src/routes/editor/editor_keydown_text.rs index abe0fc35..fed8d0e6 100644 --- a/loki-text/src/routes/editor/editor_keydown_text.rs +++ b/loki-text/src/routes/editor/editor_keydown_text.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! Printable-character and Backspace handling for the document canvas, //! including selection-aware replacement and removal (audit F6c): typing @@ -76,11 +77,15 @@ fn selection_start(a: &DocumentPosition, b: &DocumentPosition) -> DocumentPositi } /// Deletes the active selection in the CRDT only — no relayout or undo -/// commit, so a caller can batch a follow-up insert into the same undo entry. +/// commit, so a caller can batch a follow-up insert (typing) or split (Enter) +/// into the same undo entry. /// /// Returns the collapsed cursor position, or `None` when there is no active /// selection or the model rejected the range (nothing mutated either way). -fn delete_selection_in_doc(ldoc: &loro::LoroDoc, cursor: &CursorState) -> Option { +pub(super) fn delete_selection_in_doc( + ldoc: &loro::LoroDoc, + cursor: &CursorState, +) -> Option { if !cursor.has_selection() { return None; } diff --git a/loki-text/src/routes/editor/editor_path_sync.rs b/loki-text/src/routes/editor/editor_path_sync.rs index 44057864..14585f4a 100644 --- a/loki-text/src/routes/editor/editor_path_sync.rs +++ b/loki-text/src/routes/editor/editor_path_sync.rs @@ -19,6 +19,7 @@ use crate::editing::cursor::CursorState; use crate::editing::saved_state::SavedStateHandle; use crate::editing::state::DocumentState; use crate::sessions::{DocSession, DocSessions}; +use crate::tabs::OpenTab; /// All per-document signals reset or restored on tab switch, grouped to keep /// the [`sync_path_and_reset`] signature manageable. @@ -45,6 +46,7 @@ pub(super) fn sync_path_and_reset( path: &str, path_signal: &mut Signal, doc_state: &Arc>, + tabs: Signal>, mut sessions: Signal, sig: &mut PathSyncSignals, ) { @@ -59,7 +61,7 @@ pub(super) fn sync_path_and_reset( ); path_signal.set(path.to_owned()); - stash_outgoing(¤t, doc_state, &mut sessions, sig); + stash_outgoing(¤t, doc_state, tabs, &mut sessions, sig); // Transient UI state never carries across documents. sig.dismiss_font_warning.set(false); @@ -74,14 +76,18 @@ pub(super) fn sync_path_and_reset( } } -/// Move the outgoing document's live state into the session map (no-op when -/// no document ever finished loading in this editor). +/// Move the outgoing document's live state into the session map. No-op when no +/// document ever finished loading, or when no tab points at `old_path` any more +/// — a closed (or Save-As-repointed) tab must not resurrect its old state on +/// reopen. The liveness guard lives here so both call sites (path change and +/// the unmount hook) are covered uniformly. /// /// Called on path change (doc → doc tab switch) and from the unmount hook in /// `EditorInner` (doc → Home navigation unmounts the editor route). pub(super) fn stash_outgoing( old_path: &str, doc_state: &Arc>, + tabs: Signal>, sessions: &mut Signal, sig: &mut PathSyncSignals, ) { @@ -89,6 +95,9 @@ pub(super) fn stash_outgoing( return; // nothing loaded — nothing to stash }; let undo_manager = sig.undo_manager.write().take(); + if !tabs.peek().iter().any(|t| t.path == old_path) { + return; // tab closed or Save-As-repointed — do not resurrect on reopen + } let (document, generation, page_count, paginated_layout, page_width_px, page_height_px) = match doc_state.lock() { Ok(s) => ( diff --git a/loki-text/src/routes/editor/editor_pointer.rs b/loki-text/src/routes/editor/editor_pointer.rs index 2f849cac..fe5ea511 100644 --- a/loki-text/src/routes/editor/editor_pointer.rs +++ b/loki-text/src/routes/editor/editor_pointer.rs @@ -61,6 +61,7 @@ pub(super) fn make_mousemove_handler( mut cursor_state: Signal, page_gap_px: f32, view_mode: Signal, + zoom_percent: Signal, ) -> impl FnMut(MouseEvent) { move |evt: MouseEvent| { // Reflow drag-select is handled per-tile (clean tile-local coordinates); @@ -94,8 +95,11 @@ pub(super) fn make_mousemove_handler( ) }; let Some(layout) = layout_opt else { return }; + // Tiles are painted at `zoom` scale and centred at that painted width, so + // the centring origin and the hit-test both use the zoom factor. + let zoom = zoom_percent() as f32 / 100.0; let viewport = Viewport::new(scroll_metrics.peek().client_width); - let x_off = viewport.centred_origin_x(page_width_px); + let x_off = viewport.centred_origin_x(page_width_px * zoom); let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); if let Some(p) = hit_test_document( cx, @@ -106,6 +110,7 @@ pub(super) fn make_mousemove_handler( page_width_px, page_height_px, page_gap_px, + zoom, ) { cursor_state.write().focus = Some(p); } @@ -123,6 +128,7 @@ pub(super) fn make_touchmove_handler( page_gap_px: f32, view_mode: Signal, scroll_metrics: Signal, + zoom_percent: Signal, ) -> impl FnMut(TouchEvent) { move |evt: TouchEvent| { let Some(mut ts) = touch_state() else { return }; @@ -162,8 +168,9 @@ pub(super) fn make_touchmove_handler( ) }; layout_opt.and_then(|layout| { + let zoom = zoom_percent() as f32 / 100.0; let viewport = Viewport::new(scroll_metrics.peek().client_width); - let x_off = viewport.centred_origin_x(page_width_px); + let x_off = viewport.centred_origin_x(page_width_px * zoom); let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); hit_test_document( start.0, @@ -174,6 +181,7 @@ pub(super) fn make_touchmove_handler( page_width_px, page_height_px, page_gap_px, + zoom, ) .map(|p| (p.page_index, p.paragraph_index, p.byte_offset)) }) @@ -205,6 +213,7 @@ pub(super) fn make_touchend_handler( page_gap_px: f32, view_mode: Signal, scroll_metrics: Signal, + zoom_percent: Signal, ) -> impl FnMut(TouchEvent) { move |_evt: TouchEvent| { let Some(ts) = touch_state() else { return }; @@ -242,8 +251,9 @@ pub(super) fn make_touchend_handler( ) }; if let Some(layout) = layout_opt { + let zoom = zoom_percent() as f32 / 100.0; let viewport = Viewport::new(scroll_metrics.peek().client_width); - let x_off = viewport.centred_origin_x(page_width_px); + let x_off = viewport.centred_origin_x(page_width_px * zoom); let origin = (x_off, tokens::TOOLBAR_HEIGHT_TOP + tokens::SPACE_6); if let Some(pos) = hit_test_document( ts.start_pos.0, @@ -254,6 +264,7 @@ pub(super) fn make_touchend_handler( page_width_px, page_height_px, page_gap_px, + zoom, ) { let loro_cursor = loro_doc.read().as_ref().and_then(|ldoc| { derive_loro_cursor(ldoc, pos.paragraph_index, pos.byte_offset) diff --git a/loki-text/src/routes/editor/mod.rs b/loki-text/src/routes/editor/mod.rs index ef279754..b7082143 100644 --- a/loki-text/src/routes/editor/mod.rs +++ b/loki-text/src/routes/editor/mod.rs @@ -10,7 +10,9 @@ //! All editing logic lives in [`editor_inner::EditorInner`]. mod editor_canvas; +mod editor_canvas_loading; mod editor_compact; +mod editor_dirty; mod editor_docked_panels; mod editor_error_view; mod editor_font_warning; @@ -21,6 +23,7 @@ mod editor_insert; mod editor_insert_panel; mod editor_keydown; mod editor_keydown_ctrl; +mod editor_keydown_enter; mod editor_keydown_text; mod editor_language_panel; mod editor_layout_task; diff --git a/loki-text/src/routes/home.rs b/loki-text/src/routes/home.rs index 61659dd3..0447633d 100644 --- a/loki-text/src/routes/home.rs +++ b/loki-text/src/routes/home.rs @@ -20,6 +20,7 @@ use super::home_util::{ use crate::new_document::{new_blank_tab, new_import_tab, new_template_tab}; use crate::recent_documents::RecentDocuments; use crate::routes::Route; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -86,6 +87,7 @@ pub fn Home() -> Element { let tabs = use_context::>>(); let active_tab = use_context::>(); + let doc_sessions = use_context::>(); let mut recent_docs = use_context::>(); // Holds the last file-picker error message, if any. @@ -192,9 +194,7 @@ pub fn Home() -> Element { } }; - // The confirmed deletion. `path` is a serialised FileAccessToken, not a - // filesystem path: decode it, delete the underlying file via the - // capability token, close any open tab for it, then drop the recents entry. + // Confirmed deletion: delete the file, close its tab + stashed session, drop recents. let mut delete_recent = move |path: String| { let mut err_sig = pick_error; match FileAccessToken::deserialize(&path) { @@ -210,7 +210,7 @@ pub fn Home() -> Element { } } - close_tab_for_path(tabs, active_tab, &path); + close_tab_for_path(tabs, active_tab, doc_sessions, &path); recent_docs.write().remove(&path); recent_docs.read().save(); }; diff --git a/loki-text/src/routes/home_util.rs b/loki-text/src/routes/home_util.rs index 9b050c8a..e301fb81 100644 --- a/loki-text/src/routes/home_util.rs +++ b/loki-text/src/routes/home_util.rs @@ -1,4 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 AppThere Loki contributors //! Tab and file-token helpers for the Home route. //! @@ -7,6 +8,7 @@ use dioxus::prelude::*; use loki_file_access::FileAccessToken; +use crate::sessions::DocSessions; use crate::tabs::OpenTab; use crate::utils::display_title_from_path; @@ -35,10 +37,18 @@ pub(super) fn push_or_switch_tab( } /// Close any open tab whose `path` matches `path`, resetting the active tab to -/// Home when the closed (or a now-shifted) tab was selected. +/// Home when the closed (or a now-shifted) tab was selected, and drop any +/// stashed editing session for that path. +/// +/// The session must be dropped here (not only in the shell's tab-close button): +/// deleting a file from the recents list while it is open, or with a session +/// stashed from an earlier tab switch, would otherwise leak the whole +/// `LoroDoc`/layout in the map — and a later file created at the same token +/// key would restore the deleted document's content instead of loading fresh. pub(super) fn close_tab_for_path( mut tabs: Signal>, mut active_tab: Signal, + mut sessions: Signal, path: &str, ) { let removed = tabs.read().iter().position(|t| t.path == path); @@ -50,6 +60,9 @@ pub(super) fn close_tab_for_path( *active_tab.write() = 0; } } + // Drop the stashed session regardless of whether a tab was open — a session + // can outlive its tab (stashed on tab switch, then the tab closed). + sessions.write().remove(path); } /// True if `name` has a template extension (Word `.dotx`/`.dotm` or diff --git a/scripts/file-ceiling-baseline.txt b/scripts/file-ceiling-baseline.txt index 96d7450f..a809759c 100644 --- a/scripts/file-ceiling-baseline.txt +++ b/scripts/file-ceiling-baseline.txt @@ -12,16 +12,16 @@ 1047 loki-spreadsheet/src/routes/editor/editor_inner.rs 978 loki-layout/src/resolve.rs 948 loki-vello/src/scene.rs -814 loki-text/src/routes/editor/editor_inner.rs +803 loki-text/src/routes/editor/editor_inner.rs 741 loki-layout/src/flow_para.rs 730 loki-ooxml/src/docx/mapper/inline.rs 651 loki-odf/src/package.rs 611 loki-ooxml/src/docx/mapper/document.rs 582 loki-ooxml/src/docx/mapper/props.rs 575 loki-ooxml/src/xlsx/import.rs -490 loki-text/src/routes/editor/editor_canvas.rs 466 loki-ooxml/src/xlsx/export.rs 465 loki-odf/src/ods/import.rs +460 loki-text/src/routes/editor/editor_canvas.rs 440 loki-doc-model/src/document.rs 388 loki-ooxml/src/docx/import.rs 373 loki-layout/src/incremental.rs From ff6ca9e711a9efb3566076f3913c980928152816 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 02:08:17 +0000 Subject: [PATCH 27/27] Fix CI lint gate: register loki-render-cpu, drop its expect() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branch's first CI run surfaced two latent failures in the Phase 3.5 crate loki-render-cpu (added earlier, never linted until now): - Dependency-direction gate: loki-render-cpu was not assigned a layer. It is a deterministic CPU rasterizer of loki-layout (L3) output — a render-layer crate parallel to loki-vello — so register it at L4 in scripts/check-dependency-direction.py and ADR-0009's tables. - clippy -D clippy::expect_used: render_page used `.expect()` on RgbaImage::from_raw in library code. Replace it with a typed RenderCpuError::BufferMismatch via ok_or (CLAUDE.md: no expect() in library code). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JoCCHAHCJDr5FqgWkMaEo7 --- .claude/settings.local.json | 3 ++- docs/adr/0009-target-architecture.md | 3 ++- loki-render-cpu/src/lib.rs | 21 +++++++++++++++++++-- scripts/check-dependency-direction.py | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 88ae21b9..44b95bf9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -100,7 +100,8 @@ "Bash(echo \"exit: $?\")", "Bash(claude plugin *)", "Bash(cp \"/root/.claude/uploads/995af0a3-e865-5b96-b002-e90985880970/cc51b77a-spec01codebaseauditandarchitecture.md\" \"/home/user/loki/docs/adr/spec-01-codebase-audit-and-architecture.md\" && echo \"placed\" && ls -la docs/adr/spec-01-codebase-audit-and-architecture.md)", - "mcp__code-review-graph__list_graph_stats_tool" + "mcp__code-review-graph__list_graph_stats_tool", + "mcp__Claude_Code_Remote__send_later" ] }, "enableAllProjectMcpServers": true, diff --git a/docs/adr/0009-target-architecture.md b/docs/adr/0009-target-architecture.md index 924ac5fd..fe8b8201 100644 --- a/docs/adr/0009-target-architecture.md +++ b/docs/adr/0009-target-architecture.md @@ -44,7 +44,7 @@ leaf foundation. L5 ui / appthere-ui · loki-app-shell app-shell (design system, shared runtime services: SpellService, i18n) ─────────────────────────────────────────────────────────── - L4 render loki-renderer · loki-vello · appthere-canvas · loki-render-cache + L4 render loki-renderer · loki-vello · appthere-canvas · loki-render-cache · loki-render-cpu ─────────────────────────────────────────────────────────── L3b export+ loki-pdf (exporters that REUSE layout for positioning) L3 layout loki-layout (pagination · Parley text layout) @@ -231,6 +231,7 @@ mechanical. None require a rewrite — consistent with D3: the target is reached | appthere-canvas | L4 | loki-render-cache | | loki-vello | L4 | appthere-canvas, loki-layout | | loki-renderer | L4 | appthere-canvas, loki-doc-model, loki-layout, loki-vello (A-8 `appthere-ui` edge removed) | +| loki-render-cpu | L4 | loki-layout (deterministic CPU rasterizer; conformance candidate render path) | | appthere-ui | L5 | loki-i18n | | loki-app-shell | L5 | loki-i18n, loki-spell | | loki-text | L6 | appthere-ui, loki-app-shell, loki-doc-model, loki-epub, loki-fonts, loki-i18n, loki-layout, loki-odf, loki-ooxml, loki-pdf, loki-renderer, loki-templates, loki-vello | diff --git a/loki-render-cpu/src/lib.rs b/loki-render-cpu/src/lib.rs index 31cae8cc..bed72b31 100644 --- a/loki-render-cpu/src/lib.rs +++ b/loki-render-cpu/src/lib.rs @@ -46,6 +46,16 @@ pub enum RenderCpuError { /// The page dimensions overflow the rasterizer's u16 pixel space. #[error("page pixel size {0}x{1} exceeds the rasterizer limit (65535)")] PageTooLarge(u32, u32), + /// The rasterizer's pixel buffer did not match the page dimensions. + #[error("pixel buffer ({len} bytes) does not match {width}x{height} RGBA")] + BufferMismatch { + /// Buffer length in bytes. + len: usize, + /// Page width in pixels. + width: u32, + /// Page height in pixels. + height: u32, + }, } /// White paper, matching the production painter's page background. @@ -128,8 +138,15 @@ pub fn render_page( ctx.render_to_pixmap(&mut resources, &mut pixmap); let data = pixmap.data_as_u8_slice().to_vec(); - Ok(RgbaImage::from_raw(px_w, px_h, data) - .expect("pixmap dimensions match the buffer by construction")) + let len = data.len(); + // `from_raw` only returns `None` if the buffer length ≠ width*height*4; + // it matches by construction here, but surface a typed error rather than + // panic (no `.expect()` in library code — CLAUDE.md). + RgbaImage::from_raw(px_w, px_h, data).ok_or(RenderCpuError::BufferMismatch { + len, + width: px_w, + height: px_h, + }) } /// Renders every page of the layout, in page order. diff --git a/scripts/check-dependency-direction.py b/scripts/check-dependency-direction.py index 256383ae..a5395004 100755 --- a/scripts/check-dependency-direction.py +++ b/scripts/check-dependency-direction.py @@ -42,7 +42,7 @@ "loki-pdf": 3.5, # L4 render "loki-render-cache": 4, "appthere-canvas": 4, "loki-vello": 4, - "loki-renderer": 4, + "loki-renderer": 4, "loki-render-cpu": 4, # L5 ui / app-shell "appthere-ui": 5, "loki-app-shell": 5, # L6 app binaries