diff --git a/.gitattributes b/.gitattributes index dfe0770..1907931 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ -# Auto detect text files and perform LF normalization -* text=auto +# Golden frames are compared byte for byte. A CRLF checkout on Windows would fail every +# snapshot test in CI for reasons that have nothing to do with the code (OXD-036). +*.snap text eol=lf diff --git a/BACKLOG.md b/BACKLOG.md index 2d248e0..2871489 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -74,97 +74,104 @@ Critical path: `OXD-001 → OXD-010 → OXD-011 → OXD-012 → OXD-018 → OXD- ## Current State **Milestone v0.1 (OXD-027) is complete and tagged `v0.1.0`.** Phases 0-2 are implemented, tested -and verified against this file: `oxidiris BACKLOG.md` reads it as 5 494 tokens across 81 headings. +and verified against this file: `oxidiris BACKLOG.md` reads it as 6 047 tokens across 83 headings. The release notes are in [`CHANGELOG.md`](CHANGELOG.md). +**Phase 3 is under way for v0.2.** OXD-030, 031, 032, 034 and 036 are done: the split view, the +outline sidebar, Review Mode and the modal keymap all work, and whole-screen golden frames guard +the layout. OXD-033 is nearly free (see its card) and OXD-035 is untouched; OXD-037 tags the +release once both land. + | | | |---|---| -| Tests | 175 passing (81 engine, 81 terminal, 12 corpus, 1 doctest) | +| Tests | 260 passing (83 engine, 163 terminal incl. 14 golden frames, 12 corpus, 2 doctests) | | Gate | `make check` — fmt, clippy `-D warnings`, tests, wasm constraint | -| Verified | Anchor column fixed across the full Unicode corpus; deadline scheduler drift-free over 1 000 steps; live pty session confirms key dispatch, help popup and terminal restore | +| Verified | Anchor column fixed across the full Unicode corpus and asserted as a golden frame; deadline scheduler drift-free over 1 000 steps; no colour reaches the terminal under `NO_COLOR`; live pty session confirms key dispatch, help popup and terminal restore | Deviations from the spec found during implementation are recorded in `docs/decisions/` and back-annotated into the spec, per the note at the bottom of this file. So far: ADR 001 -(`token-timing.md`), which replaced `Token::duration_ms` with a WPM-independent weight. +(`token-timing.md`), which replaced `Token::duration_ms` with a WPM-independent weight; ADR 002 +(`split-view-layout.md`), which moved the status block out of the left column and made the panel a +width decision rather than a size-class one. Known gaps carried forward, all deliberate and tracked: -- Split view, outline and search are Phase 3-4 (OXD-031, OXD-032, OXD-043) +- Search is Phase 4 (OXD-043) - CJK still splits on whitespace only — blocked on **DEC-02**, not papered over - `--theme`, `--chunk`, `--start`, `--no-resume`, `--config` parse but report themselves as unimplemented in the status bar rather than failing silently - `-` (stdin) falls back to `--dump`: the TUI cannot read keys while stdin is a pipe. Proper support needs OXD-047 -- **CI is red on `main`.** The MSRV and licence jobs have failed since before the v0.1.0 tag: - the declared MSRV of 1.85 is impossible (ratatui 0.30 needs 1.88) and `deny.toml` rejects the - project's own GPL crates. `make check` detects neither. Diagnosed in **OXD-005** / issue - [#4](https://github.com/AlexanderSlokov/oxidiris/issues/4), scheduled for v0.2.0 because - raising the MSRV is a compatibility change +- **v0.1.0 as tagged does not build on the MSRV it declares.** It advertises 1.85; ratatui 0.30 + needs 1.88. Fixed on `main` by **OXD-005** (issue + [#4](https://github.com/AlexanderSlokov/oxidiris/issues/4)), which turned CI green again, but + the published tag keeps the wrong claim — it is recorded under that release's known limitations + in `CHANGELOG.md` and corrected in v0.2.0 --- ## Overview Table -| ID | Task | Phase | Size | Depends | Status | -|---|---|---|---|---|---| -| OXD-001 | Split Cargo workspace | 0 | M | — | ✅ Done | -| OXD-002 | Metadata & MSRV | 0 | S | OXD-001 | ✅ Done | -| OXD-003 | CI baseline | 0 | M | OXD-001 | ✅ Done | -| OXD-004 | `testdata/` corpus | 0 | S | OXD-001 | ✅ Done | -| OXD-005 | Correct MSRV & licence policy ([#4](https://github.com/AlexanderSlokov/oxidiris/issues/4)) | 0 | S | OXD-002, OXD-003 | ⬜ Todo | -| OXD-010 | Token & Document data types | 1 | M | OXD-001 | ✅ Done | -| OXD-011 | Unicode Segmenter | 1 | M | OXD-010 | ✅ Done | -| OXD-012 | ORP Algorithm | 1 | M | OXD-011 | ✅ Done | -| OXD-013 | Pacing Engine | 1 | M | OXD-010 | ✅ Done | -| OXD-014 | Sentence boundary heuristic | 1 | S | OXD-013 | ✅ Done | -| OXD-015 | Encoding Detection | 1 | S | OXD-001 | ✅ Done | -| OXD-016 | Plain Text Parser | 1 | M | OXD-010 | ✅ Done | -| OXD-017 | Markdown Parser | 1 | L | OXD-016 | ✅ Done | -| OXD-018 | Player state machine | 1 | L | OXD-012, OXD-013 | ✅ Done | -| OXD-020 | CLI Framework (clap) | 2 | M | OXD-001 | ✅ Done | -| OXD-021 | Event loop + deadline clock | 2 | L | OXD-018 | ✅ Done | -| OXD-022 | Terminal capability detection | 2 | M | OXD-020 | ✅ Done | -| OXD-023 | RSVP Widget (anchor column) | 2 | L | OXD-021, OXD-022 | ✅ Done | -| OXD-024 | Status bar & progress bar | 2 | S | OXD-023 | ✅ Done | -| OXD-025 | Help popup `?` | 2 | S | OXD-023 | ✅ Done | -| OXD-026 | `--dump` mode | 2 | S | OXD-017, OXD-020 | ✅ Done | -| OXD-027 | Milestone v0.1 | 2 | S | OXD-023…026 | ✅ Done | -| OXD-030 | Modal keymap system | 3 | M | OXD-021 | ⬜ Todo | -| OXD-031 | Full-Text Panel + highlight | 3 | L | OXD-030 | ⬜ Todo | -| OXD-032 | Outline / TOC Sidebar | 3 | M | OXD-031 | ⬜ Todo | -| OXD-033 | Backstep, Skip, paragraph jump | 3 | M | OXD-030 | ⬜ Todo | -| OXD-034 | Review Mode | 3 | M | OXD-031 | ⬜ Todo | -| OXD-035 | Ramp-up on resume | 3 | S | OXD-018 | ⬜ Todo | -| OXD-036 | TUI Snapshot test | 3 | M | OXD-023 | ⬜ Todo | -| OXD-037 | Milestone v0.2 | 3 | S | OXD-005, OXD-030…036 | ⬜ Todo | -| OXD-040 | Theme system | 4 | M | OXD-022 | ⬜ Todo | -| OXD-041 | TOML config file | 4 | M | OXD-020 | ⬜ Todo | -| OXD-042 | Custom keybindings | 4 | M | OXD-030, OXD-041 | ⬜ Todo | -| OXD-043 | Search `/` | 4 | M | OXD-031 | ⬜ Todo | -| OXD-044 | Bookmarks | 4 | S | OXD-033 | ⬜ Todo | -| OXD-045 | History & auto-resume | 4 | M | OXD-044 | ⬜ Todo | -| OXD-046 | Chunking mode | 4 | M | OXD-012 | ⬜ Todo | -| OXD-047 | Read from stdin | 4 | S | OXD-020 | ⬜ Todo | -| OXD-048 | Milestone v0.3 | 4 | S | OXD-040…047 | ⬜ Todo | -| OXD-050 | LaTeX Parser | 5 | L | OXD-017 | ⬜ Todo | -| OXD-051 | Formula display strategy | 5 | M | OXD-050 | ⬜ Todo | -| OXD-052 | Typst Parser | 5 | M | OXD-051 | ⬜ Todo | -| OXD-060 | Spike: PDF extraction | 6 | M | OXD-017 | ⬜ Todo | -| OXD-061 | PDF De-columnizing | 6 | XL | OXD-060 | ⬜ Todo | -| OXD-062 | Lazy parsing & cache | 6 | L | OXD-018 | ⬜ Todo | -| OXD-070 | EPUB Parser | 7 | L | OXD-062 | ⬜ Todo | -| OXD-071 | HTML / Readability Parser | 7 | M | OXD-017 | ⬜ Todo | -| OXD-072 | `criterion` Benchmark | 7 | S | OXD-017 | ⬜ Todo | -| OXD-073 | Parser Fuzzing | 7 | M | OXD-017 | ⬜ Todo | -| OXD-074 | Accessibility (a11y) audit | 7 | M | OXD-040 | ⬜ Todo | -| OXD-075 | Rewrite README + demo | 7 | M | OXD-027 | ⬜ Todo | -| OXD-076 | Project documentation | 7 | S | — | ⬜ Todo | -| OXD-077 | Packaging & distribution | 7 | M | OXD-003 | ⬜ Todo | -| OXD-078 | Milestone v1.0 | 7 | S | OXD-070…077 | ⬜ Todo | -| OXD-080 | WebAssembly Bindings | 8 | L | OXD-018 | ⬜ Todo | -| OXD-081 | Interactive landing page | 8 | L | OXD-080 | ⬜ Todo | -| OXD-082 | Neovim Plugin | 8 | XL | OXD-078 | ⬜ Todo | -| OXD-083 | VS Code Extension | 8 | XL | OXD-080 | ⬜ Todo | +| ID | Task | Phase | Size | Depends | Status | +|---------|--------------------------------------------------------------------------------------------|-------|------|----------------------|---------| +| OXD-001 | Split Cargo workspace | 0 | M | — | ✅ Done | +| OXD-002 | Metadata & MSRV | 0 | S | OXD-001 | ✅ Done | +| OXD-003 | CI baseline | 0 | M | OXD-001 | ✅ Done | +| OXD-004 | `testdata/` corpus | 0 | S | OXD-001 | ✅ Done | +| OXD-005 | Correct MSRV & licence policy ([#4](https://github.com/AlexanderSlokov/oxidiris/issues/4)) | 0 | S | OXD-002, OXD-003 | ✅ Done | +| OXD-010 | Token & Document data types | 1 | M | OXD-001 | ✅ Done | +| OXD-011 | Unicode Segmenter | 1 | M | OXD-010 | ✅ Done | +| OXD-012 | ORP Algorithm | 1 | M | OXD-011 | ✅ Done | +| OXD-013 | Pacing Engine | 1 | M | OXD-010 | ✅ Done | +| OXD-014 | Sentence boundary heuristic | 1 | S | OXD-013 | ✅ Done | +| OXD-015 | Encoding Detection | 1 | S | OXD-001 | ✅ Done | +| OXD-016 | Plain Text Parser | 1 | M | OXD-010 | ✅ Done | +| OXD-017 | Markdown Parser | 1 | L | OXD-016 | ✅ Done | +| OXD-018 | Player state machine | 1 | L | OXD-012, OXD-013 | ✅ Done | +| OXD-020 | CLI Framework (clap) | 2 | M | OXD-001 | ✅ Done | +| OXD-021 | Event loop + deadline clock | 2 | L | OXD-018 | ✅ Done | +| OXD-022 | Terminal capability detection | 2 | M | OXD-020 | ✅ Done | +| OXD-023 | RSVP Widget (anchor column) | 2 | L | OXD-021, OXD-022 | ✅ Done | +| OXD-024 | Status bar & progress bar | 2 | S | OXD-023 | ✅ Done | +| OXD-025 | Help popup `?` | 2 | S | OXD-023 | ✅ Done | +| OXD-026 | `--dump` mode | 2 | S | OXD-017, OXD-020 | ✅ Done | +| OXD-027 | Milestone v0.1 | 2 | S | OXD-023…026 | ✅ Done | +| OXD-030 | Modal keymap system | 3 | M | OXD-021 | ✅ Done | +| OXD-031 | Full-Text Panel + highlight | 3 | L | OXD-030 | ✅ Done | +| OXD-032 | Outline / TOC Sidebar | 3 | M | OXD-031 | ✅ Done | +| OXD-033 | Backstep, Skip, paragraph jump (only `%` left) | 3 | S | OXD-030 | ⬜ Todo | +| OXD-034 | Review Mode | 3 | M | OXD-031 | ✅ Done | +| OXD-035 | Ramp-up on resume | 3 | S | OXD-018 | ⬜ Todo | +| OXD-036 | TUI Snapshot test | 3 | M | OXD-023 | ✅ Done | +| OXD-037 | Milestone v0.2 | 3 | S | OXD-005, OXD-030…036 | ⬜ Todo | +| OXD-040 | Theme system | 4 | M | OXD-022 | ⬜ Todo | +| OXD-041 | TOML config file | 4 | M | OXD-020 | ⬜ Todo | +| OXD-042 | Custom keybindings | 4 | M | OXD-030, OXD-041 | ⬜ Todo | +| OXD-043 | Search `/` | 4 | M | OXD-031 | ⬜ Todo | +| OXD-044 | Bookmarks | 4 | S | OXD-033 | ⬜ Todo | +| OXD-045 | History & auto-resume | 4 | M | OXD-044 | ⬜ Todo | +| OXD-046 | Chunking mode | 4 | M | OXD-012 | ⬜ Todo | +| OXD-047 | Read from stdin | 4 | S | OXD-020 | ⬜ Todo | +| OXD-048 | Milestone v0.3 | 4 | S | OXD-040…047 | ⬜ Todo | +| OXD-050 | LaTeX Parser | 5 | L | OXD-017 | ⬜ Todo | +| OXD-051 | Formula display strategy | 5 | M | OXD-050 | ⬜ Todo | +| OXD-052 | Typst Parser | 5 | M | OXD-051 | ⬜ Todo | +| OXD-060 | Spike: PDF extraction | 6 | M | OXD-017 | ⬜ Todo | +| OXD-061 | PDF De-columnizing | 6 | XL | OXD-060 | ⬜ Todo | +| OXD-062 | Lazy parsing & cache | 6 | L | OXD-018 | ⬜ Todo | +| OXD-070 | EPUB Parser | 7 | L | OXD-062 | ⬜ Todo | +| OXD-071 | HTML / Readability Parser | 7 | M | OXD-017 | ⬜ Todo | +| OXD-072 | `criterion` Benchmark | 7 | S | OXD-017 | ⬜ Todo | +| OXD-073 | Parser Fuzzing | 7 | M | OXD-017 | ⬜ Todo | +| OXD-074 | Accessibility (a11y) audit | 7 | M | OXD-040 | ⬜ Todo | +| OXD-075 | Rewrite README + demo | 7 | M | OXD-027 | ⬜ Todo | +| OXD-076 | Project documentation | 7 | S | — | ⬜ Todo | +| OXD-077 | Packaging & distribution | 7 | M | OXD-003 | ⬜ Todo | +| OXD-078 | Milestone v1.0 | 7 | S | OXD-070…077 | ⬜ Todo | +| OXD-080 | WebAssembly Bindings | 8 | L | OXD-018 | ⬜ Todo | +| OXD-081 | Interactive landing page | 8 | L | OXD-080 | ⬜ Todo | +| OXD-082 | Neovim Plugin | 8 | XL | OXD-078 | ⬜ Todo | +| OXD-083 | VS Code Extension | 8 | XL | OXD-080 | ⬜ Todo | --- @@ -667,9 +674,10 @@ Scope - Explicit distinction between `Esc` (exit sub-mode) and `q` (quit app) per §7.2 note. Acceptance -- [ ] `J` changes WPM in Reader, scrolls in Browser -- [ ] `Esc` in Browser returns to Reader, does not quit app -- [ ] Keymap data structure enumerable for help popup +- [x] `J` changes WPM in Reader, scrolls in Browser +- [x] `Esc` in Browser returns to Reader, does not quit app +- [x] Keymap data structure enumerable for help popup — `bindings_for(mode)` drives the help + popup and the status hints, so neither can advertise a key that does something else here --- @@ -683,10 +691,18 @@ Scope - Highlight current token based on `byte_span` — primary context anchor preventing disorientation. - `Tab` switches focus; manual scrolling enabled when paused. +> The panel renders `Document::source`, markup included, because that is what `byte_span` indexes. +> Rendering stripped block text would need a second mapping that could disagree with the first, and +> a highlight on the wrong word is worse than no panel. The §5 mockup draws it this way too. +> +> Known limit, by design: for Markdown escapes and character entities the fragment's text is not a +> verbatim copy of its source range, and `pacing::word_span` falls back to highlighting the whole +> fragment. A slightly coarse highlight beats a range that panics on slicing. + Acceptance -- [ ] Highlight strictly matches currently displayed token in left panel -- [ ] Auto-scroll keeps current token within visible viewport -- [ ] Auto-hide right panel when terminal < 80 columns (§3.4.4) +- [x] Highlight strictly matches currently displayed token in left panel +- [x] Auto-scroll keeps current token within visible viewport +- [x] Auto-hide right panel when terminal < 80 columns (§3.4.4) — on width alone, see ADR 002 --- @@ -700,9 +716,9 @@ Scope - Highlight heading containing active reading position. Acceptance -- [ ] `Enter` jumps to exact location and returns to Reader mode -- [ ] Document without headings → friendly empty state notice, no confusing empty panel -- [ ] Nested heading levels rendered with proper indentation +- [x] `Enter` jumps to exact location and returns to Reader mode +- [x] Document without headings → friendly empty state notice, no confusing empty panel +- [x] Nested heading levels rendered with proper indentation --- @@ -714,10 +730,17 @@ Scope — `H`/`←` back 5 words · `L`/`→` forward 5 words · `[`/`]` jump pa > Note: Paragraph boundary defined by parser `block_id`, not text heuristics — LaTeX/PDF paragraph boundaries differ significantly from Markdown (§3.3). +> **Mostly shipped in v0.1.0.** Every binding above except `%` is already in +> `keymap.rs::BINDINGS` and backed by `Player::{seek_words, seek_blocks, goto_start, goto_end}`; +> `Player::seek_ratio` exists too. What remains is the numeric prefix — reading digits before `%` +> needs a pending-input state, which is why this now sits after OXD-030 rather than beside it. +> Real remaining size: **S**. Wiring `--start ` belongs here too. + Acceptance -- [ ] Seeking at doc boundaries cleanly clamped -- [ ] Paragraph jumps use `block_id`, functioning across `.txt` and `.md` +- [x] Seeking at doc boundaries cleanly clamped +- [x] Paragraph jumps use `block_id`, functioning across `.txt` and `.md` - [ ] Entering `50%` jumps precisely to midpoint of document +- [ ] `--start 50%` starts there and stops reporting itself as unimplemented --- @@ -732,9 +755,10 @@ Scope - Auto-pause when opened; `Esc` closes and resumes reading. Acceptance -- [ ] Displays exact original paragraph text, including stripped markup -- [ ] Paragraphs longer than screen are scrollable -- [ ] Closing resumes playback at exact previous position +- [x] Displays exact original paragraph text, including stripped markup +- [x] Paragraphs longer than screen are scrollable +- [x] Closing resumes playback at exact previous position — and a finished document is not + silently restarted by closing the popup --- @@ -758,10 +782,15 @@ Phase 3 · Depends OXD-023 · Crate bin · Spec §9.1 · Size M · Parallel-safe Scope — `ratatui::backend::TestBackend` + `insta`, covering: focus mode, split view, help popup, outline, terminal sizes 80×24 / 200×50 / 40×10, color and `NO_COLOR`. +> Two grids are captured per frame: the characters, and a style grid that records how each cell is +> emphasised. The §3.4.1 guarantees are about attributes, so an ORP that quietly stopped being bold +> would pass a text-only snapshot. + Acceptance -- [ ] Snapshots deterministic across test runs -- [ ] Test confirms ORP column invariant across varying word lengths -- [ ] Runs in CI across all 3 OS platforms +- [x] Snapshots deterministic across test runs +- [x] Test confirms ORP column invariant across varying word lengths +- [ ] Runs in CI across all 3 OS platforms — pending the first CI run on the Phase 3 PR. + `.gitattributes` pins `*.snap` to LF so a Windows checkout cannot fail them spuriously --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a7a6a..3eef50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,45 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the ## [Unreleased] -Next up is Phase 3 (`OXD-030`…`OXD-037`): modal keymap, full-text panel, outline sidebar and -Review Mode. See [`BACKLOG.md`](BACKLOG.md). +Phase 3 lands the context features (`OXD-030`…`OXD-036`). They are not extras: RSVP works by +removing the reader's sense of place, and this is the half of the design that gives it back. See +[`BACKLOG.md`](BACKLOG.md). + +### Added + +- **Full-text panel.** The document beside the reader frame, scrolling itself to follow the + cursor, with the word currently on screen highlighted. The highlight is driven by + `Token::byte_span`, so it cannot drift out of step with what is being read. Appears whenever the + window is at least 80 columns wide, and is the default layout now that it exists — `--mode + focus` still gives the bare reader frame. `OXD-031`. +- **Outline sidebar** (`o`). The heading tree from the parser, with the section you are in marked + and `Enter` to jump to any of it. On windows too narrow for a third column it is drawn over the + reader instead. `OXD-032`. +- **Review Mode** (`v`). Pauses and shows the paragraph just read, verbatim and markup included, + reconstructed from `Block::byte_span`. `Esc` closes it and resumes at the same word. This is the + controlled version of the backward glance RSVP removes. `OXD-034`. +- **Modal keymap.** `Tab` moves focus to the text panel, `o` to the outline; while a panel has + focus `J`/`K` scroll it rather than changing speed. The key reference lists the bindings that + work where you currently are, and both the popup and the dispatcher still read one table. + `OXD-030`, resolving the conflict spec §7.1 flagged. +- `Player::seek_block_id`, so a caller holding a `Heading` can jump to it without knowing token + indices. +- Movement keys scroll an open popup instead of changing a speed the reader cannot see; the key + reference has been scrollable in principle since v0.1.0 but had no key bound to it. +- **Golden-frame tests** (`OXD-036`): whole-screen snapshots at 80x24, 200x50 and 40x10, in every + mode, plus a style grid that asserts the ORP stays bold *and* underlined and the panel highlight + stays reversed with no colour emitted at all under `NO_COLOR`. ### Changed +- `--mode` now defaults to `tui`. It degrades to `focus` on its own below 80 columns, so the + default is safe on any terminal, and `--mode tui` no longer reports itself as unimplemented. +- The status block spans the full window width rather than sitting inside the left column as spec + §5 drew it, and panel visibility is decided on width alone rather than on a size class that also + weighed height. Both changes are recorded in + [`docs/decisions/split-view-layout.md`](docs/decisions/split-view-layout.md) (ADR 002) and + back-annotated into the spec. + - **Minimum supported Rust version raised from 1.85 to 1.88.** Edition 2024 only needs 1.85, but `ratatui` 0.30 and its dependency tree require 1.88, so the workspace never actually built on the version v0.1.0 declared. Raising it changes the compatibility promise, which is why it diff --git a/Cargo.lock b/Cargo.lock index 29d7687..7ba50f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -253,6 +253,17 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -417,6 +428,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -608,6 +625,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "instability" version = "0.3.13" @@ -868,6 +897,7 @@ dependencies = [ "anyhow", "clap", "crossterm", + "insta", "oxidiris-core", "ratatui", ] @@ -1450,6 +1480,12 @@ dependencies = [ "libc", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index d8672e0..3ab8bee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ encoding_rs = "0.8" # --- dev --- proptest = "1" +insta = "1" [profile.release] lto = "thin" diff --git a/Makefile b/Makefile index 0a7b2d7..7632bc0 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ MSRV := $(shell grep -m1 '^rust-version' Cargo.toml | cut -d'"' -f2) .DEFAULT_GOAL := help .PHONY: help build release run demo dump frame test test-core test-tui fmt fmt-check lint \ - check wasm msrv doc doc-open bench audit clean install tree ci + check wasm msrv doc doc-open bench audit clean install tree ci snap ## help: list the available targets help: @@ -109,6 +109,13 @@ doc: doc-open: cargo doc -p $(CORE) --no-deps --open +## snap: review changed golden frames (needs: cargo install cargo-insta) +# A snapshot diff is a question, not a failure: it means the layout moved. Look before accepting. +snap: + @command -v cargo-insta >/dev/null || \ + { echo "cargo-insta not installed. Run: cargo install cargo-insta"; exit 1; } + cargo insta review + ## frame: print one rendered frame to stdout (visual check, no terminal needed) frame: @cargo test -q -p $(BIN) --bin $(BIN) -- --ignored --nocapture render_frame_of_the_backlog diff --git a/README.md b/README.md index 3d55546..9b40bc6 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,28 @@ A Rust TUI that leverages RSVP to stream docs to your focus point — zero eye movement required. ``` -┌ BACKLOG.md ──────────────────────────────────────────────────────────────────┐ -│ │ -│ ▼ │ -│ Rules │ -│ ▲ │ -│ │ -└──────────────────────────────────────────────────────────────────────────────┘ +┌ BACKLOG.md ──────────────────────────┐┌ Text · [Tab] to scroll ────────────┐ +│ ││## How to Use This Document │ +│ ││ │ +│ ││### Task Pick Rules │ +│ ││ │ +│ ││1. Only pick tasks where all │ +│ ││`Depends` are in the Done state. Do │ +│ ││not do tasks ahead of their │ +│ ▼ ││dependencies without permission. │ +│ ahead ││2. Tasks labeled BLOCKED-BY-HUMAN │ +│ ▲ ││require human decisions (see [Section │ +│ ││D](#d-decisions-requiring-human-input)│ +│ ││) — do not decide independently. │ +│ ││3. Tasks labeled Parallel-safe can be │ +│ ││worked on concurrently with other │ +│ ││tasks in the same phase without file │ +│ ││conflicts. │ +│ ││4. Update task status in the overview │ +│ ││table when starting (`In progress`) │ +└──────────────────────────────────────┘└──────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│speed: 400 WPM (eff. 295) word: 67/5494 (1%)│ +│speed: 400 WPM (eff. 297) word: 83/6047 (1%)│ │[Space] play [K / Up] speed [H / Left] seek [?] help [q] quit ░░░░░░░│ └──────────────────────────────────────────────────────────────────────────────┘ ``` @@ -20,10 +33,15 @@ The highlighted character is the **Optimal Recognition Point**, and it is drawn terminal column for every word in the document. That is the whole idea: your eye fixates once and the text moves instead. +The panel on the right is the other half of the idea. RSVP takes the page away, and with it your +sense of where you are; the panel puts it back, scrolling itself and highlighting the word the +reader frame is showing. It appears whenever the window is at least 80 columns wide. + ## Status -**v0.1.0** — reads `.md` and `.txt`. See [`CHANGELOG.md`](CHANGELOG.md) for what this release -contains and [`BACKLOG.md`](BACKLOG.md) for what comes next. +**v0.2.0 in development** — reads `.md` and `.txt`, with the split view, outline sidebar and +Review Mode. See [`CHANGELOG.md`](CHANGELOG.md) for what has landed and +[`BACKLOG.md`](BACKLOG.md) for what comes next. ## What it is not @@ -48,6 +66,7 @@ Requires Rust 1.88 or newer. ```sh oxidiris README.md # read at the default 300 WPM oxidiris paper.md -w 450 # start faster +oxidiris paper.md -m focus # hide the text panel, reader frame only oxidiris notes.txt --pacing linear # even timing, no punctuation pauses oxidiris BACKLOG.md --dump # clean plain text on stdout oxidiris BACKLOG.md | less # piping produces text, not a broken TUI @@ -55,6 +74,12 @@ oxidiris BACKLOG.md | less # piping produces text, not a broken TUI ### Keys +The keymap is **modal**: `Tab` moves focus to the text panel and `o` to the outline, and while a +panel has focus `J`/`K` scroll it instead of changing speed. `Esc` always returns to reading; only +`q` leaves the program. `?` lists the keys that work where you currently are. + +While reading: + | Key | Action | |-------------------|---------------------------| | `Space` | Play / pause | @@ -64,9 +89,24 @@ oxidiris BACKLOG.md | less # piping produces text, not a broken TUI | `[` / `]` | Previous / next paragraph | | `g` / `G` | Start / end | | `R` | Restart | +| `Tab` | Focus the text panel | +| `o` | Outline sidebar | +| `v` | Review the last paragraph | | `?` | Key reference | | `q` | Quit | +In the text panel or the outline: + +| Key | Action | +|-------------------|-------------------------------| +| `K` `↑` / `J` `↓` | Scroll / move the selection | +| `Enter` | Jump to the selected heading | +| `Tab` / `Esc` | Back to reading | + +**Review Mode** (`v`) pauses and shows the paragraph you just read, verbatim, markup included. +RSVP works by removing the backward glance that repairs a misparse; this is that glance, on +purpose. `Esc` closes it and picks up exactly where you were. + ## Accessibility Oxidiris is assistive technology, so these are requirements rather than nice-to-haves: @@ -77,6 +117,8 @@ Oxidiris is assistive technology, so these are requirements rather than nice-to- photosensitivity risk, and the reader warns once past 700 WPM (WCAG SC 2.3.1). - **`NO_COLOR` is honored,** and color degrades from truecolor through 256 to 16 to none. - **Everything is keyboard-driven.** There is no mouse-only action. +- **The panel highlight is reversed video, not a colour.** Like the anchor, it survives a + monochrome terminal; a snapshot test asserts that no colour at all is emitted under `NO_COLOR`. - **`--dump` is the screen-reader route.** A self-rewriting frame and a screen reader cannot cooperate, so there is a plain-text path out. @@ -102,7 +144,8 @@ The workspace is two crates: - **`oxidiris`** — the terminal application. Design notes live in [`docs/informations/proposals.md`](docs/informations/proposals.md); decisions -that departed from it are in [`docs/decisions/token-timing.md`](docs/decisions/token-timing.md). +that departed from it are recorded in [`docs/decisions/`](docs/decisions/) and back-annotated into +the spec. ## Licence diff --git a/crates/oxidiris-core/src/player.rs b/crates/oxidiris-core/src/player.rs index b469ad4..73eb844 100644 --- a/crates/oxidiris-core/src/player.rs +++ b/crates/oxidiris-core/src/player.rs @@ -200,6 +200,34 @@ impl Player { } } + /// Move to the first token of the block with this id. + /// + /// Returns whether such a block exists in the stream. Blocks that produce no tokens — a table, + /// or a heading whose text was entirely markup — are absent from it, so the caller cannot + /// assume every block in the document is reachable. + /// + /// This is how the outline sidebar jumps to a heading (OXD-032): [`crate::Heading`] carries a + /// `block_id`, not a token index. + /// + /// ``` + /// # use oxidiris_core::{PacingMode, Player, parser}; + /// let doc = parser::parse_markdown("# One\n\nbody\n\n# Two\n\nmore\n"); + /// let mut player = Player::from_document(&doc, 300, PacingMode::Natural); + /// let second = doc.headings[1].block_id; + /// assert!(player.seek_block_id(second)); + /// assert_eq!(player.current().unwrap().text, "Two"); + /// ``` + pub fn seek_block_id(&mut self, block_id: usize) -> bool { + let Some(idx) = self.tokens.iter().position(|t| t.block_id == block_id) else { + return false; + }; + self.cursor = idx; + if self.state == PlayState::Finished { + self.state = PlayState::Paused; + } + true + } + /// Jump to a fraction of the document, `0.0..=1.0`. pub fn seek_ratio(&mut self, ratio: f64) { if self.tokens.is_empty() { @@ -261,10 +289,31 @@ mod tests { p.seek_words(-10); p.seek_blocks(1); p.seek_ratio(0.5); + assert!(!p.seek_block_id(0)); p.restart(); assert!(p.current().is_none()); } + #[test] + fn seeking_to_a_block_id_lands_on_its_first_token() { + let mut p = player_from(DOC); + p.goto_end(); + let last_block = p.current().unwrap().block_id; + assert!(p.seek_block_id(last_block)); + assert_eq!(p.current().unwrap().text, "Third"); + assert_eq!(p.state(), PlayState::Paused, "a jump out of the end must leave Finished"); + } + + /// Tables and markup-only blocks contribute no tokens, so not every block id is reachable. + #[test] + fn seeking_to_an_unknown_block_id_reports_failure_and_does_not_move() { + let mut p = player_from(DOC); + p.seek_words(3); + let before = p.progress().0; + assert!(!p.seek_block_id(9_999)); + assert_eq!(p.progress().0, before); + } + #[test] fn advance_walks_to_the_end_then_finishes() { let mut p = player_from(DOC); diff --git a/crates/oxidiris/Cargo.toml b/crates/oxidiris/Cargo.toml index 7127e6a..fe8f51f 100644 --- a/crates/oxidiris/Cargo.toml +++ b/crates/oxidiris/Cargo.toml @@ -16,4 +16,7 @@ oxidiris-core = { path = "../oxidiris-core", version = "0.1.0" } ratatui.workspace = true crossterm.workspace = true clap.workspace = true -anyhow.workspace = true \ No newline at end of file +anyhow.workspace = true + +[dev-dependencies] +insta.workspace = true diff --git a/crates/oxidiris/src/app.rs b/crates/oxidiris/src/app.rs index 41136af..414c02d 100644 --- a/crates/oxidiris/src/app.rs +++ b/crates/oxidiris/src/app.rs @@ -1,40 +1,97 @@ //! Application state and the key dispatcher. +use core::ops::Range; + use oxidiris_core::player::{SEEK_WORDS, WPM_STEP_COARSE, WPM_STEP_FINE}; -use oxidiris_core::{Document, PacingMode, Player}; +use oxidiris_core::{Document, PacingMode, PlayState, Player}; + +use crate::keymap::{Action, Mode}; +use crate::term::{Capabilities, MIN_WIDTH, SizeClass, size_class}; +use crate::ui; +use crate::ui::{outline, panel}; -use crate::keymap::Action; -use crate::term::Capabilities; +/// The full-text panel's wrapped view of the document. +/// +/// Wrapping is cached rather than redone per frame: at 1 500 WPM the screen redraws 25 times a +/// second, and re-wrapping a 50 KB document that often is work nobody asked for. +#[derive(Debug, Default)] +pub struct PanelView { + /// Byte ranges into [`Document::source`], one per display row. + pub rows: Vec>, + /// Inner width the rows were wrapped to. + pub width: u16, + /// Inner height last seen, used to clamp scrolling. + pub height: u16, + /// First visible row while the reader is scrolling by hand. + pub scroll: u16, +} + +/// Move a scroll offset by `delta`, keeping at least one row of content on screen. +fn step(current: u16, delta: isize, rows: usize, viewport: u16) -> u16 { + let last = rows.saturating_sub(usize::from(viewport.max(1))); + let next = (current as isize + delta).clamp(0, u16::MAX as isize) as usize; + next.min(last) as u16 +} /// Everything the renderer needs to draw a frame. pub struct App { /// Playback cursor over the token stream. pub player: Player, + /// The parsed document, kept for the panel, the outline and Review Mode. + pub doc: Document, /// Title shown on the reader frame, normally the file name. pub title: String, /// What the terminal can render. pub caps: Capabilities, + /// Which panel owns the keyboard (spec §7.1). + pub mode: Mode, + /// Whether the reader asked for the split layout at all (`--mode tui`). + pub split: bool, + /// Whether the outline sidebar is open. + pub show_outline: bool, + /// Whether Review Mode is open. + pub show_review: bool, /// Whether the key reference is open. pub show_help: bool, /// Scroll offset inside the key reference. pub help_scroll: u16, + /// Scroll offset inside Review Mode. + pub review_scroll: u16, + /// Highlighted entry in the outline. + pub outline_selected: usize, + /// Wrapped view of the document for the full-text panel. + pub panel: PanelView, /// Transient note shown in place of the key hints. pub message: Option, /// Set once the reader has asked to leave. pub should_quit: bool, + /// Last known terminal size, from [`App::relayout`]. + size: (u16, u16), + /// Whether playback was interrupted by a popup and should pick up again afterwards. + resume_when_done: bool, } impl App { /// Build an application over a parsed document. - pub fn new(doc: &Document, title: String, wpm: u16, pacing: PacingMode) -> Self { + pub fn new(doc: Document, title: String, wpm: u16, pacing: PacingMode) -> Self { App { - player: Player::from_document(doc, wpm, pacing), + player: Player::from_document(&doc, wpm, pacing), + doc, title, caps: Capabilities::default(), + mode: Mode::Reader, + split: true, + show_outline: false, + show_review: false, show_help: false, help_scroll: 0, + review_scroll: 0, + outline_selected: 0, + panel: PanelView::default(), message: None, should_quit: false, + size: (0, 0), + resume_when_done: false, } } @@ -50,11 +107,93 @@ impl App { self } + /// Choose between the split layout and the bare reader frame (`--mode`). + pub fn with_split(mut self, split: bool) -> Self { + self.split = split; + self + } + + /// Recompute the layout for a terminal of this size, re-wrapping the panel if it changed. + /// + /// Called before every draw. Cheap when nothing moved: the wrap is redone only when the + /// panel's inner width actually differs. + pub fn relayout(&mut self, width: u16, height: u16) { + self.size = (width, height); + let panes = ui::panes( + ratatui::layout::Rect { x: 0, y: 0, width, height }, + self.outline_docked(), + self.panel_visible(), + ); + let Some(area) = panes.panel else { return }; + let inner = (area.width.saturating_sub(2), area.height.saturating_sub(2)); + if inner.0 != self.panel.width || self.panel.rows.is_empty() { + self.panel.rows = panel::wrap(&self.doc.source, inner.0); + self.panel.width = inner.0; + } + self.panel.height = inner.1; + self.panel.scroll = + panel::clamp_scroll(self.panel.scroll, self.panel.height, self.panel.rows.len()); + } + + /// Whether the full-text panel is on screen. + /// + /// The test is on *width* alone: spec §3.4.4 drops the panel below 80 columns, and a short + /// window is no reason to take it away — a 100x20 terminal has plenty of room beside the + /// reader frame, just not much above it. + pub fn panel_visible(&self) -> bool { + self.split && self.has_room() + } + + /// Whether the outline has room for its own column, rather than covering the reader. + pub fn outline_docked(&self) -> bool { + self.show_outline && self.has_room() + } + + /// Whether the window is wide enough for a second column beside the reader. + fn has_room(&self) -> bool { + self.size.0 >= MIN_WIDTH && size_class(self.size.0, self.size.1) != SizeClass::TooSmall + } + + /// Whether the outline has to be drawn over the reader instead of beside it. + pub fn outline_overlaid(&self) -> bool { + self.show_outline && !self.outline_docked() + } + /// The word currently on screen. pub fn current_word(&self) -> Option<&str> { self.player.current().map(|t| t.text.as_str()) } + /// Source range of the word currently on screen, for the panel highlight. + pub fn current_span(&self) -> Option> { + self.player.current().map(|t| t.byte_span.clone()) + } + + /// Source range of the block the cursor is in, for Review Mode. + pub fn current_block_span(&self) -> Option> { + let block_id = self.player.current()?.block_id; + self.doc.blocks.iter().find(|b| b.id == block_id).map(|b| b.byte_span.clone()) + } + + /// Outline entry whose section contains the cursor. + pub fn active_heading(&self) -> Option { + let block_id = self.player.current()?.block_id; + outline::active_index(&self.doc.headings, block_id) + } + + /// First panel row to draw. + /// + /// While the panel has focus the reader owns the scroll; otherwise it follows the cursor, + /// which is the behaviour that keeps them oriented (OXD-031 acceptance). + pub fn panel_scroll(&self) -> u16 { + if self.mode == Mode::Browser { + return self.panel.scroll; + } + let Some(span) = self.current_span() else { return 0 }; + let Some(row) = panel::row_of(&self.panel.rows, span.start) else { return 0 }; + panel::auto_scroll(row, self.panel.height, self.panel.rows.len()) + } + /// Apply an action. pub fn handle(&mut self, action: Action) { // The message is a response to the last thing that happened, so any new input clears it. @@ -62,11 +201,18 @@ impl App { self.message = None; } - match action { - Action::TogglePlay => { - self.show_help = false; - self.player.toggle(); + // An open popup owns the movement keys while it is up: `J`/`K` scroll it rather than + // changing a speed the reader cannot currently see the effect of. + if self.show_help || self.show_review { + match action { + Action::Faster | Action::ScrollUp => return self.scroll_popup(-1), + Action::Slower | Action::ScrollDown => return self.scroll_popup(1), + _ => {} } + } + + match action { + Action::TogglePlay => self.toggle_play(), Action::Restart => self.player.restart(), Action::Faster => self.player.adjust_wpm(i32::from(WPM_STEP_COARSE)), Action::Slower => self.player.adjust_wpm(-i32::from(WPM_STEP_COARSE)), @@ -78,21 +224,161 @@ impl App { Action::NextBlock => self.player.seek_blocks(1), Action::GotoStart => self.player.goto_start(), Action::GotoEnd => self.player.goto_end(), - Action::ToggleHelp => { - self.show_help = !self.show_help; - self.help_scroll = 0; - // Opening the reference stops the stream; reading and reading about reading do not - // mix. - if self.show_help { - self.player.pause(); - } + Action::FocusPanel => self.focus_panel(), + Action::ToggleOutline => self.toggle_outline(), + Action::ToggleReview => self.toggle_review(), + Action::ScrollUp => self.scroll(-1), + Action::ScrollDown => self.scroll(1), + Action::Select => self.select(), + Action::ToggleHelp => self.toggle_help(), + Action::Escape => self.escape(), + Action::Quit => self.should_quit = true, + } + } + + /// Space always means "get back to reading", wherever it is pressed. + fn toggle_play(&mut self) { + self.show_help = false; + self.show_review = false; + self.mode = Mode::Reader; + self.resume_when_done = false; + self.player.toggle(); + } + + fn focus_panel(&mut self) { + if self.mode.is_panel() { + self.mode = Mode::Reader; + self.resume(); + return; + } + if !self.panel_visible() { + self.message = Some("the text panel needs an 80-column window".into()); + return; + } + // Seed the manual scroll from where the panel already is, so focusing it does not jump. + self.panel.scroll = self.panel_scroll(); + self.mode = Mode::Browser; + self.suspend(); + } + + fn toggle_outline(&mut self) { + if self.show_outline { + self.show_outline = false; + if self.mode == Mode::Outline { + self.mode = Mode::Reader; + self.resume(); } - Action::Escape => { - if self.show_help { - self.show_help = false; - } + return; + } + self.show_outline = true; + self.mode = Mode::Outline; + self.outline_selected = self.active_heading().unwrap_or(0); + self.suspend(); + } + + fn toggle_review(&mut self) { + if self.show_review { + self.show_review = false; + self.resume(); + return; + } + if self.current_block_span().is_none() { + self.message = Some("nothing to review yet".into()); + return; + } + self.show_review = true; + self.review_scroll = 0; + self.suspend(); + } + + fn scroll(&mut self, delta: isize) { + match self.mode { + Mode::Browser => { + let next = (self.panel.scroll as isize + delta).clamp(0, u16::MAX as isize); + self.panel.scroll = + panel::clamp_scroll(next as u16, self.panel.height, self.panel.rows.len()); } - Action::Quit => self.should_quit = true, + Mode::Outline => { + let last = self.doc.headings.len().saturating_sub(1); + let next = self.outline_selected as isize + delta; + self.outline_selected = next.clamp(0, last as isize) as usize; + } + Mode::Reader => {} + } + } + + /// Move the open popup's viewport, clamped to its content. + fn scroll_popup(&mut self, delta: isize) { + if self.show_help { + let rows = crate::ui::help::lines(self.mode).len(); + self.help_scroll = step(self.help_scroll, delta, rows, self.size.1.saturating_sub(2)); + return; + } + let Some(span) = self.current_block_span() else { return }; + // Mirrors the popup geometry in `ui::review`; both derive from the terminal size. + let width = (self.size.0 * 6 / 8).saturating_sub(2); + let height = (self.size.1 * 5 / 8).max(3).saturating_sub(2); + let rows = crate::ui::review::row_count(&self.doc.source, span, width); + self.review_scroll = step(self.review_scroll, delta, rows, height); + } + + fn select(&mut self) { + let Some(heading) = self.doc.headings.get(self.outline_selected) else { + self.message = Some("this document has no headings".into()); + return; + }; + if !self.player.seek_block_id(heading.block_id) { + self.message = Some(format!("nothing readable under {:?}", heading.text)); + return; + } + self.mode = Mode::Reader; + self.resume(); + } + + fn toggle_help(&mut self) { + self.show_help = !self.show_help; + self.help_scroll = 0; + // Opening the reference stops the stream; reading and reading about reading do not mix. + if self.show_help { + self.suspend(); + } else { + self.resume(); + } + } + + /// Esc peels off one layer at a time: popup, then sub-mode, then nothing (spec §7.2). + fn escape(&mut self) { + if self.show_help { + self.show_help = false; + } else if self.show_review { + self.show_review = false; + } else if self.mode.is_panel() { + self.mode = Mode::Reader; + } else { + return; + } + self.resume(); + } + + /// Hold playback while something else has the reader's attention. + fn suspend(&mut self) { + if self.player.is_playing() { + self.resume_when_done = true; + self.player.pause(); + } + } + + /// Pick playback up again, but only once every distraction is gone. + fn resume(&mut self) { + let busy = self.show_help || self.show_review || self.mode.is_panel(); + if !self.resume_when_done || busy { + return; + } + self.resume_when_done = false; + // Finished means the cursor is parked on the last token; playing again would silently + // restart the document, which is not what closing a popup should do. + if self.player.state() == PlayState::Paused { + self.player.play(); } } } @@ -100,19 +386,30 @@ impl App { #[cfg(test)] mod tests { use super::*; - use oxidiris_core::{PlayState, parser, segment}; + use oxidiris_core::{parser, segment}; const DOC: &str = "\ # Heading one First paragraph with a handful of words in it. +## Heading two + Second paragraph, also with several words. "; fn app() -> App { let doc = parser::parse_markdown(&segment::sanitize(DOC)); - App::new(&doc, "test.md".into(), 300, PacingMode::Natural) + let mut a = App::new(doc, "test.md".into(), 300, PacingMode::Natural); + a.relayout(120, 40); + a + } + + fn narrow_app() -> App { + let doc = parser::parse_markdown(&segment::sanitize(DOC)); + let mut a = App::new(doc, "test.md".into(), 300, PacingMode::Natural); + a.relayout(60, 20); + a } #[test] @@ -155,6 +452,221 @@ Second paragraph, also with several words. assert!(a.player.current().unwrap().block_id > first); } + // --- modes (OXD-030) ---------------------------------------------------- + + #[test] + fn tab_moves_focus_to_the_panel_and_back() { + let mut a = app(); + assert_eq!(a.mode, Mode::Reader); + a.handle(Action::FocusPanel); + assert_eq!(a.mode, Mode::Browser); + a.handle(Action::FocusPanel); + assert_eq!(a.mode, Mode::Reader); + } + + /// Esc leaves the sub-mode; only q leaves the program (spec §7.2). + #[test] + fn escape_returns_to_the_reader_without_quitting() { + let mut a = app(); + a.handle(Action::FocusPanel); + a.handle(Action::Escape); + assert_eq!(a.mode, Mode::Reader); + assert!(!a.should_quit); + } + + #[test] + fn focusing_a_hidden_panel_says_why_instead_of_doing_nothing() { + let mut a = narrow_app(); + a.handle(Action::FocusPanel); + assert_eq!(a.mode, Mode::Reader); + assert!(a.message.as_deref().unwrap().contains("80-column"), "got {:?}", a.message); + } + + #[test] + fn browsing_pauses_playback_and_leaving_resumes_it() { + let mut a = app(); + a.handle(Action::TogglePlay); + assert!(a.player.is_playing()); + a.handle(Action::FocusPanel); + assert!(!a.player.is_playing(), "browsing while the stream runs is unreadable"); + a.handle(Action::FocusPanel); + assert!(a.player.is_playing(), "leaving the panel should pick reading back up"); + } + + #[test] + fn a_reader_who_was_paused_stays_paused_after_browsing() { + let mut a = app(); + a.handle(Action::FocusPanel); + a.handle(Action::Escape); + assert!(!a.player.is_playing()); + } + + #[test] + fn space_returns_to_reading_from_anywhere() { + let mut a = app(); + a.handle(Action::ToggleOutline); + a.handle(Action::TogglePlay); + assert_eq!(a.mode, Mode::Reader); + assert!(a.player.is_playing()); + } + + // --- panel (OXD-031) ---------------------------------------------------- + + #[test] + fn the_panel_is_wrapped_to_its_own_width() { + let a = app(); + assert!(!a.panel.rows.is_empty()); + assert!(a.panel.width > 0); + for row in &a.panel.rows { + assert!(a.doc.source.get(row.clone()).is_some()); + } + } + + #[test] + fn a_narrow_window_hides_the_panel_entirely() { + let a = narrow_app(); + assert!(!a.panel_visible()); + } + + /// Enough paragraphs that the panel has to scroll at all. + fn long_app() -> App { + let text: String = + (0..40).map(|i| format!("Paragraph number {i} with a few words in it.\n\n")).collect(); + let doc = parser::parse_markdown(&segment::sanitize(&text)); + let mut a = App::new(doc, "long.md".into(), 300, PacingMode::Natural); + a.relayout(120, 40); + a + } + + #[test] + fn the_panel_follows_the_cursor_until_the_reader_takes_over() { + let mut a = long_app(); + a.handle(Action::GotoEnd); + let followed = a.panel_scroll(); + assert!(followed > 0, "the panel should have scrolled to the end of the document"); + + a.handle(Action::FocusPanel); + assert_eq!(a.panel_scroll(), followed, "focusing the panel must not make it jump"); + a.handle(Action::ScrollUp); + assert!(a.panel_scroll() < followed, "manual scrolling must win while browsing"); + } + + #[test] + fn scrolling_is_clamped_at_both_ends() { + let mut a = app(); + a.handle(Action::FocusPanel); + for _ in 0..50 { + a.handle(Action::ScrollUp); + } + assert_eq!(a.panel.scroll, 0); + for _ in 0..500 { + a.handle(Action::ScrollDown); + } + assert!(a.panel.scroll as usize <= a.panel.rows.len()); + } + + #[test] + fn the_highlight_span_points_at_the_word_on_screen() { + let a = app(); + let span = a.current_span().unwrap(); + assert_eq!(&a.doc.source[span], a.current_word().unwrap()); + } + + // --- outline (OXD-032) -------------------------------------------------- + + #[test] + fn opening_the_outline_selects_the_section_being_read() { + let mut a = app(); + a.handle(Action::GotoEnd); + a.handle(Action::ToggleOutline); + assert_eq!(a.mode, Mode::Outline); + assert_eq!(a.outline_selected, a.doc.headings.len() - 1); + } + + #[test] + fn selecting_a_heading_jumps_there_and_returns_to_the_reader() { + let mut a = app(); + a.handle(Action::GotoEnd); + a.handle(Action::ToggleOutline); + a.handle(Action::ScrollUp); + a.handle(Action::Select); + assert_eq!(a.mode, Mode::Reader); + assert_eq!(a.player.current().unwrap().text, "Heading"); + } + + #[test] + fn the_outline_selection_is_clamped_to_the_headings() { + let mut a = app(); + a.handle(Action::ToggleOutline); + for _ in 0..20 { + a.handle(Action::ScrollDown); + } + assert_eq!(a.outline_selected, a.doc.headings.len() - 1); + for _ in 0..20 { + a.handle(Action::ScrollUp); + } + assert_eq!(a.outline_selected, 0); + } + + #[test] + fn a_document_without_headings_reports_it_rather_than_jumping() { + let doc = parser::parse_markdown("just a paragraph, no headings at all\n"); + let mut a = App::new(doc, "flat.md".into(), 300, PacingMode::Natural); + a.relayout(120, 40); + a.handle(Action::ToggleOutline); + a.handle(Action::Select); + assert!(a.message.as_deref().unwrap().contains("no headings"), "got {:?}", a.message); + } + + #[test] + fn a_narrow_window_puts_the_outline_over_the_reader() { + let mut a = narrow_app(); + a.handle(Action::ToggleOutline); + assert!(a.outline_overlaid()); + assert!(!a.outline_docked()); + } + + // --- review mode (OXD-034) ---------------------------------------------- + + #[test] + fn review_shows_the_block_under_the_cursor_and_pauses() { + let mut a = app(); + a.handle(Action::TogglePlay); + a.handle(Action::ToggleReview); + assert!(a.show_review); + assert!(!a.player.is_playing()); + + let span = a.current_block_span().unwrap(); + assert!(a.doc.source[span].contains("Heading one")); + } + + #[test] + fn closing_review_resumes_at_the_same_position() { + let mut a = app(); + a.handle(Action::Forward); + let before = a.player.progress().0; + a.handle(Action::TogglePlay); + a.handle(Action::ToggleReview); + a.handle(Action::Escape); + assert!(!a.show_review); + assert!(a.player.is_playing()); + assert_eq!(a.player.progress().0, before); + } + + #[test] + fn a_finished_document_is_not_restarted_by_closing_a_popup() { + let mut a = app(); + a.handle(Action::TogglePlay); + a.handle(Action::GotoEnd); + while a.player.advance().is_some() {} + assert_eq!(a.player.state(), PlayState::Finished); + a.handle(Action::ToggleReview); + a.handle(Action::Escape); + assert_eq!(a.player.progress().0, a.player.tokens().len(), "the cursor jumped to the top"); + } + + // --- popups and messages ------------------------------------------------ + /// Reading and reading about reading do not mix. #[test] fn opening_help_pauses_playback() { @@ -166,7 +678,40 @@ Second paragraph, also with several words. assert!(!a.player.is_playing()); } - /// Esc leaves the popup; only q leaves the program (spec §7.2). + /// While a popup is up, `J`/`K` move the popup — changing a speed you cannot see is useless. + #[test] + fn movement_keys_scroll_an_open_popup_instead_of_changing_speed() { + let mut a = app(); + a.relayout(80, 12); // short enough that the key reference actually overflows + let wpm = a.player.wpm(); + a.handle(Action::ToggleHelp); + a.handle(Action::Slower); + assert_eq!(a.player.wpm(), wpm, "the speed must not move behind a popup"); + assert_eq!(a.help_scroll, 1); + a.handle(Action::Faster); + assert_eq!(a.help_scroll, 0); + a.handle(Action::Faster); + assert_eq!(a.help_scroll, 0, "scrolling must stop at the top"); + } + + #[test] + fn review_scrolls_within_its_paragraph_and_stops_at_the_end() { + let text: String = + (0..30).map(|i| format!("sentence {i} of one very long paragraph ")).collect(); + let doc = parser::parse_markdown(&segment::sanitize(&text)); + let mut a = App::new(doc, "long.md".into(), 300, PacingMode::Natural); + a.relayout(80, 24); + a.handle(Action::ToggleReview); + a.handle(Action::ScrollDown); + assert_eq!(a.review_scroll, 1); + for _ in 0..200 { + a.handle(Action::ScrollDown); + } + let bottom = a.review_scroll; + a.handle(Action::ScrollDown); + assert_eq!(a.review_scroll, bottom, "scrolling must stop at the last row"); + } + #[test] fn escape_closes_the_popup_without_quitting() { let mut a = app(); @@ -177,10 +722,11 @@ Second paragraph, also with several words. } #[test] - fn escape_with_no_popup_open_does_nothing() { + fn escape_with_nothing_open_does_nothing() { let mut a = app(); a.handle(Action::Escape); assert!(!a.should_quit); + assert_eq!(a.mode, Mode::Reader); } #[test] @@ -192,7 +738,8 @@ Second paragraph, also with several words. #[test] fn any_navigation_clears_a_pending_message() { - let mut a = app().with_message(Some("note".into())); + let mut a = app(); + a.message = Some("note".into()); a.handle(Action::Forward); assert!(a.message.is_none()); } @@ -207,7 +754,8 @@ Second paragraph, also with several words. #[test] fn an_empty_document_survives_every_action() { let doc = parser::parse_markdown(""); - let mut a = App::new(&doc, "empty.md".into(), 300, PacingMode::Natural); + let mut a = App::new(doc, "empty.md".into(), 300, PacingMode::Natural); + a.relayout(120, 40); for action in [ Action::TogglePlay, Action::Forward, @@ -218,9 +766,28 @@ Second paragraph, also with several words. Action::GotoStart, Action::Restart, Action::Faster, + Action::FocusPanel, + Action::ScrollUp, + Action::ScrollDown, + Action::ToggleOutline, + Action::Select, + Action::ToggleReview, + Action::Escape, ] { a.handle(action); } assert!(a.current_word().is_none()); + assert_eq!(a.panel_scroll(), 0); + } + + /// Resizing must not leave the panel wrapped to a width that no longer exists. + #[test] + fn resizing_rewraps_the_panel() { + let mut a = long_app(); + let at_120 = a.panel.rows.len(); + a.relayout(80, 24); + assert!(a.panel.rows.len() > at_120, "a narrower panel needs more rows"); + a.relayout(120, 40); + assert_eq!(a.panel.rows.len(), at_120, "going back to a known width must reuse its wrap"); } } diff --git a/crates/oxidiris/src/cli.rs b/crates/oxidiris/src/cli.rs index 35c2e97..5fbb8d0 100644 --- a/crates/oxidiris/src/cli.rs +++ b/crates/oxidiris/src/cli.rs @@ -10,7 +10,7 @@ use oxidiris_core::pacing::{DEFAULT_WPM, MAX_WPM, MIN_WPM}; /// Display mode for the reader. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Mode { - /// Split screen: RSVP frame plus the full text panel. Arrives in v0.2 (OXD-031). + /// Split screen: RSVP frame plus the full text panel. Tui, /// Minimal: only the RSVP frame, centred. Focus, @@ -54,11 +54,13 @@ EXAMPLES: oxidiris README.md Read a file at the default 300 WPM oxidiris paper.md -w 450 Start at 450 words per minute oxidiris notes.txt --pacing linear Disable punctuation-aware pauses + oxidiris paper.md -m focus Hide the text panel, RSVP frame only oxidiris BACKLOG.md --dump Print clean plain text and exit oxidiris BACKLOG.md | less Piping also produces plain text KEYS: - Space play/pause J/K speed H/L seek [/] paragraph ? help q quit" + Space play/pause J/K speed H/L seek [/] paragraph Tab panel + o outline v review ? help q quit" )] pub struct Cli { /// Document to read. Use `-` to read from standard input. @@ -69,7 +71,10 @@ pub struct Cli { pub wpm: u16, /// Display mode. - #[arg(short = 'm', long, value_enum, default_value_t = Mode::Focus)] + /// + /// `tui` is the default now that the full-text panel exists (OXD-031); it degrades to `focus` + /// on its own below 80 columns, so the default is safe on any terminal. + #[arg(short = 'm', long, value_enum, default_value_t = Mode::Tui)] pub mode: Mode, /// Pacing strategy. @@ -120,9 +125,6 @@ impl Cli { /// Flags that parse but are not wired up yet, so the reader can say so instead of pretending. pub fn unimplemented_flags(&self) -> Vec<&'static str> { let mut v = Vec::new(); - if self.mode == Mode::Tui { - v.push("--mode tui (split view lands in v0.2, OXD-031)"); - } if self.theme.is_some() { v.push("--theme (OXD-040)"); } @@ -156,7 +158,7 @@ mod tests { fn defaults_are_the_safe_ones() { let cli = Cli::try_parse_from(["oxidiris", "a.md"]).unwrap(); assert_eq!(cli.wpm, DEFAULT_WPM); - assert_eq!(cli.mode, Mode::Focus); + assert_eq!(cli.mode, Mode::Tui, "the split view is the default layout since v0.2"); assert_eq!(cli.pacing, Pacing::Natural); assert!(!cli.dump); } diff --git a/crates/oxidiris/src/event.rs b/crates/oxidiris/src/event.rs index 6422532..4971be8 100644 --- a/crates/oxidiris/src/event.rs +++ b/crates/oxidiris/src/event.rs @@ -78,6 +78,10 @@ pub fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> { loop { if dirty { + // The panel's wrap depends on its width, so the layout is settled before the draw + // rather than during it: `render` takes `&App` and cannot re-wrap on its own. + let size = terminal.size()?; + app.relayout(size.width, size.height); terminal.draw(|frame| ui::render(frame, app))?; dirty = false; } @@ -102,7 +106,7 @@ pub fn run(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> { match rx.recv_timeout(timeout) { Ok(AppEvent::Key(key)) => { - if let Some(action) = keymap::resolve(key) { + if let Some(action) = keymap::resolve(key, app.mode) { app.handle(action); dirty = true; } diff --git a/crates/oxidiris/src/keymap.rs b/crates/oxidiris/src/keymap.rs index 06a4b57..15797be 100644 --- a/crates/oxidiris/src/keymap.rs +++ b/crates/oxidiris/src/keymap.rs @@ -1,13 +1,46 @@ -//! Key bindings, declared once as data. +//! Modal key bindings, declared once as data. //! -//! Supports OXD-025. See spec §7. +//! Implements OXD-025 and OXD-030. See spec §7. //! -//! The help popup and the key dispatcher both read [`BINDINGS`], so the documented keys and the -//! working keys cannot drift apart. Phase 3 (OXD-030) turns this table into a per-mode map; the -//! shape is chosen so that change is additive. +//! # Why the map is modal +//! +//! `J`/`K` adjust speed while reading, but once focus moves to the full-text panel they have to +//! scroll it — the same key cannot mean both at once (spec §7.1). So a binding declares which +//! [`Mode`]s it applies in, and [`resolve`] is asked for a key *in a mode*. +//! +//! The help popup and the dispatcher both read [`BINDINGS`], so the documented keys and the +//! working keys cannot drift apart. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +/// Which panel currently owns the keyboard (spec §7.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Mode { + /// Reading. Keys drive playback and speed. + #[default] + Reader, + /// The full-text panel has focus. Keys scroll it. + Browser, + /// The outline sidebar has focus. Keys move the selection. + Outline, +} + +impl Mode { + /// Short label for the status bar. + pub const fn label(self) -> &'static str { + match self { + Mode::Reader => "READER", + Mode::Browser => "BROWSER", + Mode::Outline => "OUTLINE", + } + } + + /// Whether keys in this mode move a panel rather than the reading cursor. + pub const fn is_panel(self) -> bool { + matches!(self, Mode::Browser | Mode::Outline) + } +} + /// Something the reader can ask the application to do. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { @@ -35,9 +68,21 @@ pub enum Action { GotoStart, /// Jump to the last token. GotoEnd, + /// Move focus between the reader and the full-text panel. + FocusPanel, + /// Show or hide the outline sidebar. + ToggleOutline, + /// Show or hide the paragraph just read. + ToggleReview, + /// Scroll the focused panel up, or move the outline selection up. + ScrollUp, + /// Scroll the focused panel down, or move the outline selection down. + ScrollDown, + /// Act on the focused panel's selection. + Select, /// Show or hide the key reference. ToggleHelp, - /// Leave a popup, or do nothing. + /// Leave a popup or a sub-mode, and return to reading. Escape, /// Exit the program. Quit, @@ -54,92 +99,61 @@ pub struct Binding { pub group: &'static str, /// The action it maps to. pub action: Action, + /// Modes this binding applies in. + pub modes: &'static [Mode], +} + +/// Bindings that work wherever the reader is. +const ANY: &[Mode] = &[Mode::Reader, Mode::Browser, Mode::Outline]; +/// Bindings that only make sense while reading. +const READING: &[Mode] = &[Mode::Reader]; +/// Bindings that only make sense while a panel has focus. +const PANELS: &[Mode] = &[Mode::Browser, Mode::Outline]; +/// Bindings that only make sense in the outline. +const OUTLINE: &[Mode] = &[Mode::Outline]; + +/// Shorthand so the table below stays one row per binding. +const fn bind( + keys: &'static str, + description: &'static str, + group: &'static str, + action: Action, + modes: &'static [Mode], +) -> Binding { + Binding { keys, description, group, action, modes } } /// Every binding the reader can use, in the order the help popup lists them. pub const BINDINGS: &[Binding] = &[ - Binding { - keys: "Space", - description: "Play / pause", - group: "Playback", - action: Action::TogglePlay, - }, - Binding { - keys: "R", - description: "Restart from the beginning", - group: "Playback", - action: Action::Restart, - }, - Binding { - keys: "K / Up", - description: "Faster (+25 WPM)", - group: "Speed", - action: Action::Faster, - }, - Binding { - keys: "J / Down", - description: "Slower (-25 WPM)", - group: "Speed", - action: Action::Slower, - }, - Binding { - keys: "+", - description: "Faster, fine (+5 WPM)", - group: "Speed", - action: Action::FasterFine, - }, - Binding { - keys: "-", - description: "Slower, fine (-5 WPM)", - group: "Speed", - action: Action::SlowerFine, - }, - Binding { - keys: "H / Left", - description: "Back 5 words", - group: "Navigation", - action: Action::Back, - }, - Binding { - keys: "L / Right", - description: "Forward 5 words", - group: "Navigation", - action: Action::Forward, - }, - Binding { - keys: "[", - description: "Previous paragraph", - group: "Navigation", - action: Action::PrevBlock, - }, - Binding { - keys: "]", - description: "Next paragraph", - group: "Navigation", - action: Action::NextBlock, - }, - Binding { - keys: "g", - description: "Go to start", - group: "Navigation", - action: Action::GotoStart, - }, - Binding { keys: "G", description: "Go to end", group: "Navigation", action: Action::GotoEnd }, - Binding { - keys: "?", - description: "Toggle this help", - group: "System", - action: Action::ToggleHelp, - }, - Binding { keys: "Esc", description: "Close popup", group: "System", action: Action::Escape }, - Binding { keys: "q", description: "Quit", group: "System", action: Action::Quit }, + bind("Space", "Play / pause", "Playback", Action::TogglePlay, ANY), + bind("R", "Restart from the beginning", "Playback", Action::Restart, READING), + bind("K / Up", "Faster (+25 WPM)", "Speed", Action::Faster, READING), + bind("J / Down", "Slower (-25 WPM)", "Speed", Action::Slower, READING), + bind("+", "Faster, fine (+5 WPM)", "Speed", Action::FasterFine, READING), + bind("-", "Slower, fine (-5 WPM)", "Speed", Action::SlowerFine, READING), + bind("H / Left", "Back 5 words", "Navigation", Action::Back, READING), + bind("L / Right", "Forward 5 words", "Navigation", Action::Forward, READING), + bind("[", "Previous paragraph", "Navigation", Action::PrevBlock, READING), + bind("]", "Next paragraph", "Navigation", Action::NextBlock, READING), + bind("g", "Go to start", "Navigation", Action::GotoStart, READING), + bind("G", "Go to end", "Navigation", Action::GotoEnd, READING), + bind("Tab", "Focus the text panel", "Context", Action::FocusPanel, READING), + bind("o", "Outline sidebar", "Context", Action::ToggleOutline, ANY), + bind("v", "Review the paragraph just read", "Context", Action::ToggleReview, READING), + bind("K / Up", "Scroll up", "Panel", Action::ScrollUp, PANELS), + bind("J / Down", "Scroll down", "Panel", Action::ScrollDown, PANELS), + bind("Enter", "Jump to the selected heading", "Panel", Action::Select, OUTLINE), + bind("Tab", "Back to reading", "Panel", Action::FocusPanel, PANELS), + bind("?", "Toggle this help", "System", Action::ToggleHelp, ANY), + bind("Esc", "Close popup, return to reading", "System", Action::Escape, ANY), + bind("q", "Quit", "System", Action::Quit, ANY), ]; -/// Map a key event to an action. +/// Map a key event to an action, interpreted in `mode`. /// -/// Returns `None` for keys with no binding, and for key *release* events, which Windows terminals -/// emit alongside presses. -pub fn resolve(key: KeyEvent) -> Option { +/// Returns `None` for keys with no binding in this mode, and for key *release* events, which +/// Windows terminals emit alongside presses. +pub fn resolve(key: KeyEvent, mode: Mode) -> Option { use crossterm::event::KeyEventKind; if key.kind == KeyEventKind::Release { return None; @@ -150,8 +164,31 @@ pub fn resolve(key: KeyEvent) -> Option { _ => None, }; } - Some(match key.code { + if let Some(action) = resolve_anywhere(key.code) { + return Some(action); + } + match mode { + Mode::Reader => resolve_reading(key.code), + Mode::Browser | Mode::Outline => resolve_panel(key.code, mode), + } +} + +/// Keys that mean the same thing in every mode. +fn resolve_anywhere(code: KeyCode) -> Option { + Some(match code { KeyCode::Char(' ') => Action::TogglePlay, + KeyCode::Char('o') | KeyCode::Char('O') => Action::ToggleOutline, + KeyCode::Tab | KeyCode::BackTab => Action::FocusPanel, + KeyCode::Char('?') => Action::ToggleHelp, + KeyCode::Esc => Action::Escape, + KeyCode::Char('q') | KeyCode::Char('Q') => Action::Quit, + _ => return None, + }) +} + +/// Keys that drive the reading cursor. +fn resolve_reading(code: KeyCode) -> Option { + Some(match code { KeyCode::Char('r') | KeyCode::Char('R') => Action::Restart, KeyCode::Char('k') | KeyCode::Char('K') | KeyCode::Up => Action::Faster, KeyCode::Char('j') | KeyCode::Char('J') | KeyCode::Down => Action::Slower, @@ -163,13 +200,26 @@ pub fn resolve(key: KeyEvent) -> Option { KeyCode::Char(']') => Action::NextBlock, KeyCode::Char('g') => Action::GotoStart, KeyCode::Char('G') => Action::GotoEnd, - KeyCode::Char('?') => Action::ToggleHelp, - KeyCode::Esc => Action::Escape, - KeyCode::Char('q') | KeyCode::Char('Q') => Action::Quit, + KeyCode::Char('v') | KeyCode::Char('V') => Action::ToggleReview, _ => return None, }) } +/// Keys that move a focused panel. `J`/`K` scroll here rather than changing speed (spec §7.3). +fn resolve_panel(code: KeyCode, mode: Mode) -> Option { + Some(match code { + KeyCode::Char('k') | KeyCode::Char('K') | KeyCode::Up => Action::ScrollUp, + KeyCode::Char('j') | KeyCode::Char('J') | KeyCode::Down => Action::ScrollDown, + KeyCode::Enter if mode == Mode::Outline => Action::Select, + _ => return None, + }) +} + +/// Bindings that apply in `mode`, in display order. +pub fn bindings_for(mode: Mode) -> impl Iterator { + BINDINGS.iter().filter(move |b| b.modes.contains(&mode)) +} + /// Keys bound to `action`, formatted for display. /// /// The status bar hints go through here rather than hard-coding key names, so the table stays the @@ -178,10 +228,10 @@ pub fn keys_for(action: Action) -> &'static str { BINDINGS.iter().find(|b| b.action == action).map_or("", |b| b.keys) } -/// Group names in display order. -pub fn groups() -> Vec<&'static str> { +/// Group names that have at least one binding in `mode`, in display order. +pub fn groups_for(mode: Mode) -> Vec<&'static str> { let mut seen: Vec<&'static str> = Vec::new(); - for b in BINDINGS { + for b in bindings_for(mode) { if !seen.contains(&b.group) { seen.push(b.group); } @@ -198,45 +248,98 @@ mod tests { KeyEvent::new(code, KeyModifiers::NONE) } + /// Every key that appears anywhere in the table, so coverage tests cannot go stale silently. + const PROBED: &[KeyCode] = &[ + KeyCode::Char(' '), + KeyCode::Char('r'), + KeyCode::Char('k'), + KeyCode::Char('j'), + KeyCode::Char('+'), + KeyCode::Char('-'), + KeyCode::Char('h'), + KeyCode::Char('l'), + KeyCode::Char('['), + KeyCode::Char(']'), + KeyCode::Char('g'), + KeyCode::Char('G'), + KeyCode::Char('v'), + KeyCode::Char('o'), + KeyCode::Tab, + KeyCode::Enter, + KeyCode::Up, + KeyCode::Down, + KeyCode::Char('?'), + KeyCode::Esc, + KeyCode::Char('q'), + ]; + + #[test] + fn every_documented_binding_is_reachable_in_the_modes_it_claims() { + // The help popup must not promise an action the dispatcher cannot produce in that mode. + for mode in [Mode::Reader, Mode::Browser, Mode::Outline] { + let reachable: Vec = + PROBED.iter().filter_map(|c| resolve(press(*c), mode)).collect(); + for binding in bindings_for(mode) { + assert!( + reachable.contains(&binding.action), + "{:?} is documented in {mode:?} but unreachable", + binding.keys + ); + } + } + } + + /// The conflict spec §7.1 calls out: the same key, two meanings, decided by mode. + #[test] + fn j_and_k_change_speed_while_reading_and_scroll_in_a_panel() { + assert_eq!(resolve(press(KeyCode::Char('k')), Mode::Reader), Some(Action::Faster)); + assert_eq!(resolve(press(KeyCode::Char('j')), Mode::Reader), Some(Action::Slower)); + assert_eq!(resolve(press(KeyCode::Char('k')), Mode::Browser), Some(Action::ScrollUp)); + assert_eq!(resolve(press(KeyCode::Char('j')), Mode::Browser), Some(Action::ScrollDown)); + assert_eq!(resolve(press(KeyCode::Char('k')), Mode::Outline), Some(Action::ScrollUp)); + } + #[test] - fn every_documented_binding_is_reachable_from_a_key() { - // The help popup must not promise an action the dispatcher cannot produce. - let reachable: Vec = [ - KeyCode::Char(' '), - KeyCode::Char('r'), - KeyCode::Char('k'), - KeyCode::Char('j'), - KeyCode::Char('+'), - KeyCode::Char('-'), - KeyCode::Char('h'), - KeyCode::Char('l'), - KeyCode::Char('['), - KeyCode::Char(']'), - KeyCode::Char('g'), - KeyCode::Char('G'), - KeyCode::Char('?'), - KeyCode::Esc, - KeyCode::Char('q'), - ] - .into_iter() - .filter_map(|c| resolve(press(c))) - .collect(); - - for binding in BINDINGS { - assert!( - reachable.contains(&binding.action), - "binding {:?} is documented but unreachable", - binding.keys + fn arrow_keys_mirror_the_vim_keys_in_every_mode() { + for mode in [Mode::Reader, Mode::Browser, Mode::Outline] { + assert_eq!( + resolve(press(KeyCode::Up), mode), + resolve(press(KeyCode::Char('k')), mode), + "arrows diverged from hjkl in {mode:?}" + ); + assert_eq!( + resolve(press(KeyCode::Down), mode), + resolve(press(KeyCode::Char('j')), mode) ); } + assert_eq!(resolve(press(KeyCode::Left), Mode::Reader), Some(Action::Back)); + assert_eq!(resolve(press(KeyCode::Right), Mode::Reader), Some(Action::Forward)); } #[test] - fn arrow_keys_mirror_the_vim_keys() { - assert_eq!(resolve(press(KeyCode::Up)), resolve(press(KeyCode::Char('k')))); - assert_eq!(resolve(press(KeyCode::Down)), resolve(press(KeyCode::Char('j')))); - assert_eq!(resolve(press(KeyCode::Left)), resolve(press(KeyCode::Char('h')))); - assert_eq!(resolve(press(KeyCode::Right)), resolve(press(KeyCode::Char('l')))); + fn seek_keys_do_nothing_while_a_panel_has_focus() { + for code in [KeyCode::Char('h'), KeyCode::Char('l'), KeyCode::Char('['), KeyCode::Char('v')] + { + assert_eq!(resolve(press(code), Mode::Browser), None, "{code:?} leaked into Browser"); + } + } + + #[test] + fn enter_selects_only_in_the_outline() { + assert_eq!(resolve(press(KeyCode::Enter), Mode::Outline), Some(Action::Select)); + assert_eq!(resolve(press(KeyCode::Enter), Mode::Browser), None); + assert_eq!(resolve(press(KeyCode::Enter), Mode::Reader), None); + } + + #[test] + fn play_pause_and_the_system_keys_work_in_every_mode() { + for mode in [Mode::Reader, Mode::Browser, Mode::Outline] { + assert_eq!(resolve(press(KeyCode::Char(' ')), mode), Some(Action::TogglePlay)); + assert_eq!(resolve(press(KeyCode::Char('?')), mode), Some(Action::ToggleHelp)); + assert_eq!(resolve(press(KeyCode::Char('q')), mode), Some(Action::Quit)); + assert_eq!(resolve(press(KeyCode::Tab), mode), Some(Action::FocusPanel)); + assert_eq!(resolve(press(KeyCode::Char('o')), mode), Some(Action::ToggleOutline)); + } } /// Windows terminals report presses and releases; acting on both would double every keystroke. @@ -244,28 +347,28 @@ mod tests { fn key_releases_are_ignored() { let mut ev = press(KeyCode::Char(' ')); ev.kind = KeyEventKind::Release; - assert_eq!(resolve(ev), None); + assert_eq!(resolve(ev, Mode::Reader), None); } #[test] fn ctrl_c_quits_and_other_control_chords_do_nothing() { let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - assert_eq!(resolve(ctrl_c), Some(Action::Quit)); + assert_eq!(resolve(ctrl_c, Mode::Reader), Some(Action::Quit)); let ctrl_x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); - assert_eq!(resolve(ctrl_x), None); + assert_eq!(resolve(ctrl_x, Mode::Reader), None); } #[test] fn escape_and_quit_are_separate_actions() { - // Esc closes a popup; only q leaves the program (spec §7.2). - assert_eq!(resolve(press(KeyCode::Esc)), Some(Action::Escape)); - assert_eq!(resolve(press(KeyCode::Char('q'))), Some(Action::Quit)); + // Esc leaves a sub-mode; only q leaves the program (spec §7.2). + assert_eq!(resolve(press(KeyCode::Esc), Mode::Browser), Some(Action::Escape)); + assert_eq!(resolve(press(KeyCode::Char('q')), Mode::Browser), Some(Action::Quit)); } #[test] fn unbound_keys_resolve_to_nothing() { - assert_eq!(resolve(press(KeyCode::Char('z'))), None); - assert_eq!(resolve(press(KeyCode::F(5))), None); + assert_eq!(resolve(press(KeyCode::Char('z')), Mode::Reader), None); + assert_eq!(resolve(press(KeyCode::F(5)), Mode::Outline), None); } #[test] @@ -276,7 +379,18 @@ mod tests { } #[test] - fn groups_are_listed_once_each_in_order() { - assert_eq!(groups(), vec!["Playback", "Speed", "Navigation", "System"]); + fn groups_are_listed_once_each_in_order_and_vary_by_mode() { + assert_eq!( + groups_for(Mode::Reader), + vec!["Playback", "Speed", "Navigation", "Context", "System"] + ); + assert_eq!(groups_for(Mode::Browser), vec!["Playback", "Context", "Panel", "System"]); + } + + #[test] + fn speed_bindings_are_not_offered_while_a_panel_has_focus() { + let browser: Vec = bindings_for(Mode::Browser).map(|b| b.action).collect(); + assert!(!browser.contains(&Action::Faster)); + assert!(browser.contains(&Action::ScrollUp)); } } diff --git a/crates/oxidiris/src/main.rs b/crates/oxidiris/src/main.rs index 58e15ce..69256a4 100644 --- a/crates/oxidiris/src/main.rs +++ b/crates/oxidiris/src/main.rs @@ -8,6 +8,8 @@ mod dump; mod event; mod keymap; mod scheduler; +#[cfg(test)] +mod snapshots; mod term; mod ui; @@ -51,8 +53,10 @@ fn run(cli: Cli) -> Result<()> { } let message = build_notice(&cli, &doc); - let mut application = App::new(&doc, title, cli.wpm, cli.pacing.into()) + let split = cli.mode == cli::Mode::Tui; + let mut application = App::new(doc, title, cli.wpm, cli.pacing.into()) .with_detected_capabilities() + .with_split(split) .with_message(message); install_panic_hook(); diff --git a/crates/oxidiris/src/snapshots.rs b/crates/oxidiris/src/snapshots.rs new file mode 100644 index 0000000..b3b1f23 --- /dev/null +++ b/crates/oxidiris/src/snapshots.rs @@ -0,0 +1,237 @@ +//! Golden frames for the whole screen. +//! +//! Implements OXD-036. See spec §9.1. +//! +//! Every other UI test asserts one property of one widget. These assert the *composition*: what a +//! reader actually sees at a given terminal size, in a given mode, with a given colour budget. +//! They are the tests that catch a layout regression nobody thought to write an assertion for. +//! +//! Two grids are captured per frame. The text grid is what the terminal prints. The style grid +//! records how each cell is emphasised, because the accessibility guarantees in §3.4.1 are about +//! attributes, not characters: an ORP that silently stopped being bold would pass a text-only +//! snapshot. +//! +//! Run `cargo insta review` after an intentional layout change; a diff here is a question, not a +//! failure. + +use oxidiris_core::{PacingMode, parser, segment}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::style::{Modifier, Style}; + +use crate::app::App; +use crate::keymap::Action; +use crate::term::{Capabilities, ColorLevel}; +use crate::ui; + +/// A document with one of everything the layout has to place: headings at two levels, a paragraph +/// long enough to wrap, a list, and code. +const DOC: &str = "\ +# Oxidiris + +Reading at speed without moving your eyes, which needs a paragraph long enough to wrap. + +## Keys + +- Space plays and pauses +- Tab focuses the text panel + +```rust +let anchor = width / 2; +``` + +## Limits + +Skimming and triage, not a replacement for careful reading. +"; + +fn app(width: u16, height: u16, caps: Capabilities) -> App { + let doc = parser::parse_markdown(&segment::sanitize(DOC)); + let mut a = App::new(doc, "oxidiris.md".into(), 300, PacingMode::Natural); + a.caps = caps; + a.player.seek_words(9); + a.relayout(width, height); + a +} + +/// Colourless and glyph-less: the terminal a screen reader user or a `NO_COLOR` user has. +fn plain_caps() -> Capabilities { + Capabilities { color: ColorLevel::None, unicode: false } +} + +fn buffer_of(a: &App, width: u16, height: u16) -> ratatui::buffer::Buffer { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| ui::render(frame, a)).unwrap(); + terminal.backend().buffer().clone() +} + +/// The characters on screen, one row per line. +fn text_grid(a: &App, width: u16, height: u16) -> String { + let buffer = buffer_of(a, width, height); + (0..height) + .map(|y| { + let row: String = + (0..width).filter_map(|x| buffer.cell((x, y))).map(|c| c.symbol()).collect(); + row.trim_end().to_string() + }) + .collect::>() + .join("\n") +} + +/// How each cell is emphasised: `r`eversed, `b`old, `u`nderlined, `c`oloured, `.` plain. +/// +/// Reversed wins over bold and bold over underline, so one character can stand for a cell. +fn style_grid(a: &App, width: u16, height: u16) -> String { + let buffer = buffer_of(a, width, height); + (0..height) + .map(|y| { + let row: String = (0..width) + .filter_map(|x| buffer.cell((x, y))) + .map(|cell| classify(cell.style())) + .collect(); + row.trim_end().to_string() + }) + .collect::>() + .join("\n") +} + +fn classify(style: Style) -> char { + let m = style.add_modifier; + if m.contains(Modifier::REVERSED) { + 'r' + } else if m.contains(Modifier::BOLD) && m.contains(Modifier::UNDERLINED) { + 'A' // the ORP anchor: bold *and* underlined, never one alone (§3.4.1) + } else if m.contains(Modifier::BOLD) { + 'b' + } else if m.contains(Modifier::UNDERLINED) { + 'u' + } else if has_colour(style) { + 'c' + } else { + '.' + } +} + +/// Whether a style asks for an actual colour. `Reset` is the terminal's own foreground, which +/// every untouched cell carries, so it does not count. +fn has_colour(style: Style) -> bool { + matches!(style.fg, Some(c) if c != ratatui::style::Color::Reset) + || matches!(style.bg, Some(c) if c != ratatui::style::Color::Reset) +} + +#[test] +fn split_view_at_eighty_by_twenty_four() { + let a = app(80, 24, Capabilities::default()); + insta::assert_snapshot!(text_grid(&a, 80, 24)); +} + +#[test] +fn split_view_at_two_hundred_by_fifty() { + let a = app(200, 50, Capabilities::default()); + insta::assert_snapshot!(text_grid(&a, 200, 50)); +} + +/// Forty columns is below the split threshold: the panel goes away rather than being squeezed. +#[test] +fn focus_mode_at_forty_by_ten() { + let a = app(40, 10, Capabilities::default()); + insta::assert_snapshot!(text_grid(&a, 40, 10)); +} + +#[test] +fn focus_mode_when_the_reader_asked_for_it() { + let mut a = app(80, 24, Capabilities::default()); + a.split = false; + a.relayout(80, 24); + insta::assert_snapshot!(text_grid(&a, 80, 24)); +} + +#[test] +fn the_help_popup() { + let mut a = app(80, 24, Capabilities::default()); + a.handle(Action::ToggleHelp); + insta::assert_snapshot!(text_grid(&a, 80, 24)); +} + +#[test] +fn the_help_popup_lists_panel_keys_while_browsing() { + let mut a = app(80, 24, Capabilities::default()); + a.handle(Action::FocusPanel); + a.handle(Action::ToggleHelp); + insta::assert_snapshot!(text_grid(&a, 80, 24)); +} + +#[test] +fn the_outline_sidebar() { + let mut a = app(100, 24, Capabilities::default()); + a.handle(Action::ToggleOutline); + insta::assert_snapshot!(text_grid(&a, 100, 24)); +} + +#[test] +fn the_outline_over_a_narrow_window() { + let mut a = app(60, 20, Capabilities::default()); + a.handle(Action::ToggleOutline); + insta::assert_snapshot!(text_grid(&a, 60, 20)); +} + +#[test] +fn review_mode() { + let mut a = app(80, 24, Capabilities::default()); + a.handle(Action::ToggleReview); + insta::assert_snapshot!(text_grid(&a, 80, 24)); +} + +/// Under `NO_COLOR` the frame must lose colour and box glyphs, and nothing else. +#[test] +fn a_monochrome_terminal_still_shows_everything() { + let a = app(80, 24, plain_caps()); + insta::assert_snapshot!(text_grid(&a, 80, 24)); +} + +/// The accessibility contract, made visible: `A` marks a cell that is bold *and* underlined, `r` a +/// reversed one. The anchor and the panel highlight must both survive with no colour at all. +#[test] +fn emphasis_survives_a_monochrome_terminal() { + let a = app(80, 24, plain_caps()); + insta::assert_snapshot!(style_grid(&a, 80, 24)); +} + +#[test] +fn emphasis_with_colour_available() { + let a = app(80, 24, Capabilities::default()); + insta::assert_snapshot!(style_grid(&a, 80, 24)); +} + +/// Nothing may reach the terminal as colour when the reader asked for none (spec §3.4.4). +#[test] +fn no_colour_is_emitted_under_no_color() { + let a = app(80, 24, plain_caps()); + let buffer = buffer_of(&a, 80, 24); + for y in 0..24 { + for x in 0..80 { + let cell = buffer.cell((x, y)).unwrap(); + assert!(!has_colour(cell.style()), "cell ({x},{y}) is coloured: {:?}", cell.style()); + } + } +} + +/// The one invariant: the ORP column does not move, whatever the word is (spec §3.1.3). +/// +/// The rows are printed one under another so a drifting anchor is visible as a staircase. +#[test] +fn the_anchor_column_holds_across_word_lengths() { + let mut a = app(80, 24, Capabilities::default()); + let mut frames = String::new(); + for _ in 0..8 { + let word = a.current_word().unwrap_or("").to_string(); + let grid = text_grid(&a, 80, 24); + let lines: Vec<&str> = grid.lines().collect(); + let marker = lines.iter().position(|l| l.contains('\u{25bc}')).expect("no anchor marker"); + // Only the reader pane; the panel beside it changes for unrelated reasons. + let row: String = lines[marker + 1].chars().take(40).collect(); + frames.push_str(&format!("{word:>12} |{}|\n", row.trim_end())); + a.player.advance(); + } + insta::assert_snapshot!(frames); +} diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__a_monochrome_terminal_still_shows_everything.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__a_monochrome_terminal_still_shows_everything.snap new file mode 100644 index 0000000..58d8ba1 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__a_monochrome_terminal_still_shows_everything.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 80, 24)" +--- +┌ oxidiris.md ─────────────────────────┐┌ Text · [Tab] to scroll ────────────┐ +│ ││# Oxidiris │ +│ ││ │ +│ ││Reading at speed without moving your │ +│ ││eyes, which needs a paragraph long │ +│ ││enough to wrap. │ +│ ││ │ +│ ││## Keys │ +│ v ││ │ +│ needs ││- Space plays and pauses │ +│ ^ ││- Tab focuses the text panel │ +│ ││ │ +│ ││```rust │ +│ ││let anchor = width / 2; │ +│ ││``` │ +│ ││ │ +│ ││## Limits │ +│ ││ │ +│ ││Skimming and triage, not a │ +└──────────────────────────────────────┘└──────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│speed: 300 WPM (eff. 210) word: 10/42 (22%)│ +│[Space] play [K / Up] speed [H / Left] seek [?] help [q] quit ##-----│ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__emphasis_survives_a_monochrome_terminal.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__emphasis_survives_a_monochrome_terminal.snap new file mode 100644 index 0000000..825162c --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__emphasis_survives_a_monochrome_terminal.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "style_grid(&a, 80, 24)" +--- +................................................................................ +................................................................................ +................................................................................ +................................................................................ +.....................................................rrrrr...................... +................................................................................ +................................................................................ +................................................................................ +................................................................................ +....................A........................................................... +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__emphasis_with_colour_available.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__emphasis_with_colour_available.snap new file mode 100644 index 0000000..825162c --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__emphasis_with_colour_available.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "style_grid(&a, 80, 24)" +--- +................................................................................ +................................................................................ +................................................................................ +................................................................................ +.....................................................rrrrr...................... +................................................................................ +................................................................................ +................................................................................ +................................................................................ +....................A........................................................... +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ +................................................................................ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__focus_mode_at_forty_by_ten.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__focus_mode_at_forty_by_ten.snap new file mode 100644 index 0000000..1810283 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__focus_mode_at_forty_by_ten.snap @@ -0,0 +1,14 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 40, 10)" +--- +┌ oxidiris.md ─────────────────────────┐ +│ ▼ │ +│ needs │ +│ ▲ │ +│ │ +└──────────────────────────────────────┘ +┌──────────────────────────────────────┐ +│speed: 300 WPM (eff. 210) word: 10/42 │ +│[Space] play [K / Up] speed [H / L│ +└──────────────────────────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__focus_mode_when_the_reader_asked_for_it.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__focus_mode_when_the_reader_asked_for_it.snap new file mode 100644 index 0000000..af6f4e7 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__focus_mode_when_the_reader_asked_for_it.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 80, 24)" +--- +┌ oxidiris.md ─────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ ▼ │ +│ needs │ +│ ▲ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│speed: 300 WPM (eff. 210) word: 10/42 (22%)│ +│[Space] play [K / Up] speed [H / Left] seek [?] help [q] quit ██░░░░░│ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__review_mode.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__review_mode.snap new file mode 100644 index 0000000..56302d9 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__review_mode.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 80, 24)" +--- +┌ oxidiris.md ─────────────────────────┐┌ Text · [Tab] to scroll ────────────┐ +│ ││# Oxidiris │ +│ ││ │ +│ ││Reading at speed without moving your │ +│ ┌ Review · [Esc] resume ─────────────────────────────────┐ long │ +│ │Reading at speed without moving your eyes, which needs a │ │ +│ │paragraph long enough to wrap. │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────┘└──────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│speed: 300 WPM (eff. 210) word: 10/42 (22%)│ +│[Space] play [K / Up] speed [H / Left] seek [?] help [q] quit ██░░░░░│ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__split_view_at_eighty_by_twenty_four.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__split_view_at_eighty_by_twenty_four.snap new file mode 100644 index 0000000..90540b1 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__split_view_at_eighty_by_twenty_four.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 80, 24)" +--- +┌ oxidiris.md ─────────────────────────┐┌ Text · [Tab] to scroll ────────────┐ +│ ││# Oxidiris │ +│ ││ │ +│ ││Reading at speed without moving your │ +│ ││eyes, which needs a paragraph long │ +│ ││enough to wrap. │ +│ ││ │ +│ ││## Keys │ +│ ▼ ││ │ +│ needs ││- Space plays and pauses │ +│ ▲ ││- Tab focuses the text panel │ +│ ││ │ +│ ││```rust │ +│ ││let anchor = width / 2; │ +│ ││``` │ +│ ││ │ +│ ││## Limits │ +│ ││ │ +│ ││Skimming and triage, not a │ +└──────────────────────────────────────┘└──────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│speed: 300 WPM (eff. 210) word: 10/42 (22%)│ +│[Space] play [K / Up] speed [H / Left] seek [?] help [q] quit ██░░░░░│ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__split_view_at_two_hundred_by_fifty.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__split_view_at_two_hundred_by_fifty.snap new file mode 100644 index 0000000..03a5ad0 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__split_view_at_two_hundred_by_fifty.snap @@ -0,0 +1,54 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 200, 50)" +--- +┌ oxidiris.md ─────────────────────────────────────────────────────────────────────────────────────┐┌ Text · [Tab] to scroll ────────────────────────────────────────────────────────────────────────┐ +│ ││# Oxidiris │ +│ ││ │ +│ ││Reading at speed without moving your eyes, which needs a paragraph long enough to wrap. │ +│ ││ │ +│ ││## Keys │ +│ ││ │ +│ ││- Space plays and pauses │ +│ ││- Tab focuses the text panel │ +│ ││ │ +│ ││```rust │ +│ ││let anchor = width / 2; │ +│ ││``` │ +│ ││ │ +│ ││## Limits │ +│ ││ │ +│ ││Skimming and triage, not a replacement for careful reading. │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ▼ ││ │ +│ needs ││ │ +│ ▲ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│speed: 300 WPM (eff. 210) word: 10/42 (22%)│ +│[Space] play [K / Up] speed [H / Left] seek [?] help [q] quit ████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_anchor_column_holds_across_word_lengths.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_anchor_column_holds_across_word_lengths.snap new file mode 100644 index 0000000..e3f8d0e --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_anchor_column_holds_across_word_lengths.snap @@ -0,0 +1,12 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: frames +--- + needs |│ needs │| + a |│ a │| + paragraph |│ paragraph │| + long |│ long │| + enough |│ enough │| + to |│ to │| + wrap. |│ wrap. │| + Keys |│ Keys │| diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_help_popup.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_help_popup.snap new file mode 100644 index 0000000..0849785 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_help_popup.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 80, 24)" +--- +┌ oxidiris.md ───┌ Keys · READER · [?] or [Esc] to close ─────┐oll ────────────┐ +│ │Playback │ │ +│ │ Space Play / pause │ │ +│ │ R Restart from the beginning │ut moving your │ +│ │ │ragraph long │ +│ │Speed │ │ +│ │ K / Up Faster (+25 WPM) │ │ +│ │ J / Down Slower (-25 WPM) │ │ +│ │ + Faster, fine (+5 WPM) │ │ +│ │ - Slower, fine (-5 WPM) │es │ +│ │ │ panel │ +│ │Navigation │ │ +│ │ H / Left Back 5 words │ │ +│ │ L / Right Forward 5 words │; │ +│ │ [ Previous paragraph │ │ +│ │ ] Next paragraph │ │ +│ │ g Go to start │ │ +│ │ G Go to end │ │ +│ │ │ot a │ +└────────────────│Context │────────────────┘ +┌────────────────│ Tab Focus the text panel │────────────────┐ +│speed: 300 WPM (│ o Outline sidebar │ord: 10/42 (22%)│ +│[Space] play [│ v Review the paragraph just read │q] quit ██░░░░░│ +└────────────────└────────────────────────────────────────────┘────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_help_popup_lists_panel_keys_while_browsing.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_help_popup_lists_panel_keys_while_browsing.snap new file mode 100644 index 0000000..346f165 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_help_popup_lists_panel_keys_while_browsing.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 80, 24)" +--- +┌ oxidiris.md ─────────────────────────┐┌ Text · [Tab] back ─────────────────┐ +│ ││# Oxidiris │ +│ ││ │ +│ ┌ Keys · BROWSER · [?] or [Esc] to close ────┐ut moving your │ +│ │Playback │ragraph long │ +│ │ Space Play / pause │ │ +│ │ │ │ +│ │Context │ │ +│ │ o Outline sidebar │ │ +│ │ │es │ +│ │Panel │ panel │ +│ │ K / Up Scroll up │ │ +│ │ J / Down Scroll down │ │ +│ │ Tab Back to reading │; │ +│ │ │ │ +│ │System │ │ +│ │ ? Toggle this help │ │ +│ │ Esc Close popup, return to reading │ │ +│ │ q Quit │ot a │ +└────────────────└────────────────────────────────────────────┘────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│speed: 300 WPM (eff. 210) word: 10/42 (22%)│ +│BROWSER [J / Down] scroll [Tab] back [?] help [q] quit ███░░░░░░░░░░░│ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_outline_over_a_narrow_window.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_outline_over_a_narrow_window.snap new file mode 100644 index 0000000..b7d9af5 --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_outline_over_a_narrow_window.snap @@ -0,0 +1,24 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 60, 20)" +--- +┌ oxidiris.md ─────────────────────────────────────────────┐ +│ │ +│ ┌ Outline · [Enter] jump ─────────────────┐ │ +│ │▸ Oxidiris │ │ +│ │ Keys │ │ +│ │ Limits │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +└──────│ │───────┘ +┌──────│ │───────┐ +│speed:└───────────────────────────────────────────┘2 (22%)│ +│OUTLINE [J / Down] scroll [Tab] back [?] help [q] │ +└──────────────────────────────────────────────────────────┘ diff --git a/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_outline_sidebar.snap b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_outline_sidebar.snap new file mode 100644 index 0000000..490719f --- /dev/null +++ b/crates/oxidiris/src/snapshots/oxidiris__snapshots__the_outline_sidebar.snap @@ -0,0 +1,28 @@ +--- +source: crates/oxidiris/src/snapshots.rs +expression: "text_grid(&a, 100, 24)" +--- +┌ Outline · [Ente┐┌ oxidiris.md ─────────────────────────┐┌ Text · [Tab] to scroll ────────────┐ +│▸ Oxidiris ││ ││# Oxidiris │ +│ Keys ││ ││ │ +│ Limits ││ ││Reading at speed without moving your e│ +│ ││ ││which needs a paragraph long enough to│ +│ ││ ││ │ +│ ││ ││## Keys │ +│ ││ ││ │ +│ ││ ▼ ││- Space plays and pauses │ +│ ││ needs ││- Tab focuses the text panel │ +│ ││ ▲ ││ │ +│ ││ ││```rust │ +│ ││ ││let anchor = width / 2; │ +│ ││ ││``` │ +│ ││ ││ │ +│ ││ ││## Limits │ +│ ││ ││ │ +│ ││ ││Skimming and triage, not a replacement│ +│ ││ ││careful reading. │ +└──────────────────┘└──────────────────────────────────────┘└──────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│speed: 300 WPM (eff. 210) word: 10/42 (22%)│ +│OUTLINE [J / Down] scroll [Tab] back [?] help [q] quit ███████░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/crates/oxidiris/src/ui/help.rs b/crates/oxidiris/src/ui/help.rs index 54326ab..2123236 100644 --- a/crates/oxidiris/src/ui/help.rs +++ b/crates/oxidiris/src/ui/help.rs @@ -3,7 +3,8 @@ //! Implements OXD-025. See spec §7. //! //! Every line is generated from [`crate::keymap::BINDINGS`], so the popup cannot promise a key -//! that the dispatcher does not honour. +//! that the dispatcher does not honour — including the modal part: the popup lists the bindings +//! that work *here*, not every binding that exists (OXD-030). use ratatui::Frame; use ratatui::layout::Rect; @@ -11,12 +12,12 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Clear, Paragraph}; -use crate::keymap::{BINDINGS, groups}; +use crate::keymap::{Mode, bindings_for, groups_for}; -/// Build the popup body from the binding table. -pub fn lines() -> Vec> { +/// Build the popup body from the bindings that apply in `mode`. +pub fn lines(mode: Mode) -> Vec> { let mut out = Vec::new(); - for (i, group) in groups().into_iter().enumerate() { + for (i, group) in groups_for(mode).into_iter().enumerate() { if i > 0 { out.push(Line::raw("")); } @@ -24,7 +25,7 @@ pub fn lines() -> Vec> { group, Style::default().add_modifier(Modifier::BOLD | Modifier::UNDERLINED), ))); - for binding in BINDINGS.iter().filter(|b| b.group == group) { + for binding in bindings_for(mode).filter(|b| b.group == group) { out.push(Line::from(vec![ Span::raw(format!(" {:<10}", binding.keys)), Span::raw(binding.description), @@ -49,12 +50,13 @@ fn centered(area: Rect, width: u16, height: u16) -> Rect { /// Render the popup over `area`. /// /// `scroll` lets a short terminal reach the lower entries. -pub fn render(frame: &mut Frame, area: Rect, scroll: u16) { - let body = lines(); +pub fn render(frame: &mut Frame, area: Rect, mode: Mode, scroll: u16) { + let body = lines(mode); let popup = centered(area, 46, body.len() as u16 + 2); frame.render_widget(Clear, popup); - let block = Block::bordered().title(Line::from(" Keys · [?] or [Esc] to close ")); + let title = format!(" Keys · {} · [?] or [Esc] to close ", mode.label()); + let block = Block::bordered().title(Line::from(title)); frame.render_widget(Paragraph::new(body).block(block).scroll((scroll, 0)), popup); } @@ -64,34 +66,47 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; - /// The popup is generated, not hand-written: every binding must appear in it. - #[test] - fn every_binding_appears_in_the_popup() { - let rendered: String = lines() + fn rendered(mode: Mode) -> String { + lines(mode) .iter() .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) .collect::>() - .join("\n"); + .join("\n") + } - for binding in BINDINGS { - assert!(rendered.contains(binding.keys), "key {:?} missing from help", binding.keys); - assert!( - rendered.contains(binding.description), - "description {:?} missing from help", - binding.description - ); + /// The popup is generated, not hand-written: every binding must appear in it. + #[test] + fn every_binding_appears_in_the_popup_for_its_mode() { + for mode in [Mode::Reader, Mode::Browser, Mode::Outline] { + let text = rendered(mode); + for binding in bindings_for(mode) { + assert!( + text.contains(binding.keys), + "key {:?} missing from {mode:?} help", + binding.keys + ); + assert!( + text.contains(binding.description), + "description {:?} missing from {mode:?} help", + binding.description + ); + } } } + /// A key that does something else here must not be advertised with its Reader meaning. + #[test] + fn the_popup_does_not_promise_reader_keys_while_a_panel_has_focus() { + let browser = rendered(Mode::Browser); + assert!(!browser.contains("Faster"), "speed keys leaked into Browser help: {browser}"); + assert!(browser.contains("Scroll up")); + } + #[test] fn every_group_gets_a_heading() { - let rendered: String = lines() - .iter() - .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::()) - .collect::>() - .join("\n"); - for group in groups() { - assert!(rendered.contains(group), "group {group:?} missing from help"); + let text = rendered(Mode::Reader); + for group in groups_for(Mode::Reader) { + assert!(text.contains(group), "group {group:?} missing from help"); } } @@ -116,15 +131,17 @@ mod tests { #[test] fn rendering_the_popup_never_panics() { for (w, h) in [(30u16, 10u16), (80, 24), (200, 60)] { - let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); - terminal.draw(|frame| render(frame, frame.area(), 0)).unwrap(); + for mode in [Mode::Reader, Mode::Browser, Mode::Outline] { + let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); + terminal.draw(|frame| render(frame, frame.area(), mode, 0)).unwrap(); + } } } #[test] fn scrolling_reaches_the_lower_entries_on_a_short_terminal() { let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap(); - terminal.draw(|frame| render(frame, frame.area(), 12)).unwrap(); + terminal.draw(|frame| render(frame, frame.area(), Mode::Reader, 20)).unwrap(); let buffer = terminal.backend().buffer(); let text: String = (0..12) .flat_map(|y| (0..60).map(move |x| (x, y))) diff --git a/crates/oxidiris/src/ui/mod.rs b/crates/oxidiris/src/ui/mod.rs index af0ef1f..f65f809 100644 --- a/crates/oxidiris/src/ui/mod.rs +++ b/crates/oxidiris/src/ui/mod.rs @@ -1,6 +1,9 @@ //! Screen composition. pub mod help; +pub mod outline; +pub mod panel; +pub mod review; pub mod rsvp; pub mod status; @@ -8,13 +11,73 @@ use ratatui::Frame; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::Line; -use ratatui::widgets::Paragraph; +use ratatui::widgets::{Clear, Paragraph}; use crate::app::App; use crate::term::{ABS_MIN_HEIGHT, ABS_MIN_WIDTH, SizeClass, size_class}; /// Rows reserved for the status block, including its border. const STATUS_HEIGHT: u16 = 4; +/// Narrowest useful outline sidebar. +const OUTLINE_MIN_WIDTH: u16 = 16; +/// Widest the outline may grow; past this it starves the reader frame. +const OUTLINE_MAX_WIDTH: u16 = 24; + +/// Where each part of the screen goes. +/// +/// Computed as a pure function so the event loop can ask for the panel's width *before* drawing, +/// and re-wrap the document only when that width actually changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Panes { + /// Outline sidebar, when it has a column of its own. + pub outline: Option, + /// The RSVP frame. Always present. + pub reader: Rect, + /// Full-text panel, when the window is wide enough for it. + pub panel: Option, + /// Status and progress block, always full width. + pub status: Rect, +} + +/// Split `area` into panes. +/// +/// > **Deviation from spec §5,** which drew the status block inside the left column. It is kept +/// > full width instead; at 80 columns a half-width status line cannot hold both the speed and the +/// > position without clipping one of them. Recorded in `docs/decisions/split-view-layout.md`. +pub fn panes(area: Rect, outline: bool, panel: bool) -> Panes { + let [top, status] = + Layout::vertical([Constraint::Min(5), Constraint::Length(STATUS_HEIGHT)]).areas(area); + + let (outline_area, rest) = if outline { + let width = (top.width / 5).clamp(OUTLINE_MIN_WIDTH, OUTLINE_MAX_WIDTH); + let [o, r] = Layout::horizontal([Constraint::Length(width), Constraint::Min(0)]).areas(top); + (Some(o), r) + } else { + (None, top) + }; + + let (reader, panel_area) = if panel { + let [l, r] = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) + .areas(rest); + (l, Some(r)) + } else { + (rest, None) + }; + + Panes { outline: outline_area, reader, panel: panel_area, status } +} + +/// Centre a `width` x `height` rectangle inside `area`, shrinking to fit if needed. +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + Rect { + x: area.x + (area.width.saturating_sub(w)) / 2, + y: area.y + (area.height.saturating_sub(h)) / 2, + width: w, + height: h, + } +} /// Draw the whole screen. pub fn render(frame: &mut Frame, app: &App) { @@ -25,14 +88,59 @@ pub fn render(frame: &mut Frame, app: &App) { return; } - let [reader_area, status_area] = - Layout::vertical([Constraint::Min(5), Constraint::Length(STATUS_HEIGHT)]).areas(area); + let panes = panes(area, app.outline_docked(), app.panel_visible()); + + if let Some(outline_area) = panes.outline { + outline::render( + frame, + outline_area, + &app.doc.headings, + app.outline_selected, + app.active_heading(), + app.mode == crate::keymap::Mode::Outline, + app.caps, + ); + } - rsvp::render(frame, reader_area, app.current_word(), &app.title, app.caps); - status::render(frame, status_area, &app.player, app.caps, app.message.as_deref()); + rsvp::render(frame, panes.reader, app.current_word(), &app.title, app.caps); + + if let Some(panel_area) = panes.panel { + let view = panel::View { + source: &app.doc.source, + rows: &app.panel.rows, + highlight: app.current_span(), + scroll: app.panel_scroll(), + focused: app.mode == crate::keymap::Mode::Browser, + caps: app.caps, + }; + panel::render(frame, panel_area, &view); + } + + status::render(frame, panes.status, &app.player, app.mode, app.caps, app.message.as_deref()); + + // Too narrow for a sidebar column, so the outline takes over the reader instead. Navigating is + // a moment of its own; playback is paused while it is open. + if app.outline_overlaid() { + let popup = centered(area, area.width * 3 / 4, area.height.saturating_sub(4)); + frame.render_widget(Clear, popup); + outline::render( + frame, + popup, + &app.doc.headings, + app.outline_selected, + app.active_heading(), + true, + app.caps, + ); + } + + let reviewing = if app.show_review { app.current_block_span() } else { None }; + if let Some(span) = reviewing { + review::render(frame, area, &app.doc.source, span, app.review_scroll); + } if app.show_help { - help::render(frame, area, app.help_scroll); + help::render(frame, area, app.mode, app.help_scroll); } } @@ -58,50 +166,121 @@ fn render_too_small(frame: &mut Frame, area: Rect) { mod tests { use super::*; use crate::app::App; + use crate::keymap::Action; use oxidiris_core::{PacingMode, parser, segment}; use ratatui::Terminal; use ratatui::backend::TestBackend; - fn app() -> App { - let text = segment::sanitize("# Title\n\nSome words to read here quickly.\n"); - let doc = parser::parse_markdown(&text); - App::new(&doc, "test.md".into(), 300, PacingMode::Natural) + const DOC: &str = "\ +# Title + +Some words to read here quickly, enough of them to wrap a narrow panel. + +## Second section + +More words follow in a second paragraph. +"; + + fn app(width: u16, height: u16) -> App { + let doc = parser::parse_markdown(&segment::sanitize(DOC)); + let mut a = App::new(doc, "test.md".into(), 300, PacingMode::Natural); + a.relayout(width, height); + a + } + + fn draw(width: u16, height: u16, a: &App) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, a)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..height) + .map(|y| (0..width).filter_map(|x| buffer.cell((x, y))).map(|c| c.symbol()).collect()) + .collect::>() + .join("\n") } #[test] fn the_full_screen_renders_at_every_supported_size() { for (w, h) in [(30u16, 10u16), (40, 12), (80, 24), (120, 40), (200, 60)] { - let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); - terminal.draw(|frame| render(frame, &app())).unwrap(); + draw(w, h, &app(w, h)); } } #[test] fn a_window_below_the_absolute_minimum_asks_to_be_enlarged() { - let mut terminal = Terminal::new(TestBackend::new(20, 6)).unwrap(); - terminal.draw(|frame| render(frame, &app())).unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..6u16) - .flat_map(|y| (0..20u16).map(move |x| (x, y))) - .filter_map(|(x, y)| buffer.cell((x, y))) - .map(|c| c.symbol()) - .collect(); - assert!(text.contains("too small"), "expected a size prompt, got {text:?}"); + let out = draw(20, 6, &app(20, 6)); + assert!(out.contains("too small"), "expected a size prompt, got {out}"); + } + + #[test] + fn a_wide_window_shows_the_reader_and_the_text_panel_side_by_side() { + let out = draw(100, 24, &app(100, 24)); + assert!(out.contains("Title"), "the document text is missing: {out}"); + assert!(out.contains("Text"), "the panel frame is missing: {out}"); + } + + /// Below 80 columns the panel is dropped rather than squeezed (spec §3.4.4). + #[test] + fn a_narrow_window_drops_the_panel() { + let out = draw(60, 20, &app(60, 20)); + assert!(!out.contains("Text ·"), "the panel should be hidden at 60 columns: {out}"); + } + + #[test] + fn the_outline_takes_a_column_on_a_wide_window() { + let mut a = app(120, 30); + a.handle(Action::ToggleOutline); + let out = draw(120, 30, &a); + assert!(out.contains("Outline"), "got {out}"); + assert!(out.contains("Second section"), "the headings are missing: {out}"); + } + + #[test] + fn the_outline_covers_the_reader_on_a_narrow_window() { + let mut a = app(60, 20); + a.handle(Action::ToggleOutline); + let out = draw(60, 20, &a); + assert!(out.contains("Outline"), "got {out}"); + } + + #[test] + fn review_mode_draws_over_everything_else() { + let mut a = app(100, 24); + a.handle(Action::ToggleReview); + let out = draw(100, 24, &a); + assert!(out.contains("Review"), "got {out}"); } #[test] fn the_help_overlay_draws_on_top_of_the_reader() { - let mut a = app(); - a.show_help = true; - let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); - terminal.draw(|frame| render(frame, &a)).unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..24u16) - .flat_map(|y| (0..80u16).map(move |x| (x, y))) - .filter_map(|(x, y)| buffer.cell((x, y))) - .map(|c| c.symbol()) - .collect(); - assert!(text.contains("Play / pause")); + let mut a = app(80, 24); + a.handle(Action::ToggleHelp); + let out = draw(80, 24, &a); + assert!(out.contains("Play / pause")); + } + + #[test] + fn panes_leave_no_gap_and_never_overlap() { + let area = Rect { x: 0, y: 0, width: 120, height: 40 }; + let p = panes(area, true, true); + let outline = p.outline.unwrap(); + let panel = p.panel.unwrap(); + assert_eq!(outline.x, 0); + assert_eq!(outline.right(), p.reader.x); + assert_eq!(p.reader.right(), panel.x); + assert_eq!(panel.right(), area.right()); + assert_eq!(p.status.width, area.width); + assert_eq!(p.reader.bottom(), p.status.y); + } + + #[test] + fn the_outline_column_never_starves_the_reader() { + for width in [80u16, 100, 200, 400] { + let p = panes(Rect { x: 0, y: 0, width, height: 40 }, true, true); + let outline = p.outline.unwrap(); + assert!(outline.width >= OUTLINE_MIN_WIDTH); + assert!(outline.width <= OUTLINE_MAX_WIDTH); + assert!(p.reader.width > outline.width / 2, "reader squeezed at {width} columns"); + } } /// Render one real frame of this project's own backlog and print it. @@ -113,31 +292,23 @@ mod tests { fn render_frame_of_the_backlog() { let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../BACKLOG.md"); let doc = oxidiris_core::load(&std::fs::read(path).unwrap(), None).unwrap(); - let mut a = App::new(&doc, "BACKLOG.md".into(), 400, PacingMode::Natural); - a.player.seek_ratio(0.012); + let headings = doc.headings.len(); + let mut a = App::new(doc, "BACKLOG.md".into(), 400, PacingMode::Natural); + a.player.seek_ratio(0.0135); + a.relayout(80, 24); - let mut terminal = Terminal::new(TestBackend::new(80, 16)).unwrap(); - terminal.draw(|frame| render(frame, &a)).unwrap(); - let buffer = terminal.backend().buffer(); - - println!(); - for y in 0..16u16 { - let line: String = - (0..80u16).filter_map(|x| buffer.cell((x, y))).map(|c| c.symbol()).collect(); - println!("{}", line.trim_end()); - } - println!("\ncurrent word: {:?}", a.current_word()); + println!("\n{}\n", draw(80, 24, &a)); + println!("current word: {:?}", a.current_word()); println!("tokens: {}", a.player.tokens().len()); - println!("headings: {}", doc.headings.len()); + println!("headings: {headings}"); } #[test] fn resizing_repeatedly_keeps_the_layout_intact() { - let a = app(); - let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let mut a = app(80, 24); for (w, h) in [(80u16, 24u16), (31, 10), (200, 50), (40, 12), (80, 24)] { - terminal.backend_mut().resize(w, h); - terminal.draw(|frame| render(frame, &a)).unwrap(); + a.relayout(w, h); + draw(w, h, &a); } } } diff --git a/crates/oxidiris/src/ui/outline.rs b/crates/oxidiris/src/ui/outline.rs new file mode 100644 index 0000000..8aa6da0 --- /dev/null +++ b/crates/oxidiris/src/ui/outline.rs @@ -0,0 +1,242 @@ +//! Outline sidebar: the document's heading tree. +//! +//! Implements OXD-032. See spec §3.3. +//! +//! The tree comes straight from [`oxidiris_core::Document::headings`], which the Markdown parser +//! fills in while it walks the AST (OXD-017). Nothing is re-derived from the text here, so an +//! outline entry always points at a block the player can actually reach. + +use oxidiris_core::Heading; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use crate::term::{Capabilities, ColorLevel}; + +/// Columns of indentation per heading level. +const INDENT: usize = 2; + +/// Index of the heading whose section contains `block_id`. +/// +/// Returns `None` while the cursor is still ahead of the first heading — a preamble, a licence +/// header — rather than pretending the first heading is active. +pub fn active_index(headings: &[Heading], block_id: usize) -> Option { + headings.iter().rposition(|h| h.block_id <= block_id) +} + +/// Scroll offset that keeps `selected` visible in a viewport `height` rows tall. +pub fn scroll_for(selected: usize, height: u16, total: usize) -> u16 { + let h = usize::from(height.max(1)); + if selected < h { + return 0; + } + let last = total.saturating_sub(h); + (selected + 1 - h).min(last) as u16 +} + +/// One outline row: indent, marker, text. +/// +/// The active section is marked with a glyph as well as a style, because a reader who cannot see +/// the style still needs to know where they are (WCAG SC 1.4.1, spec §3.4.1). +fn row<'a>( + heading: &'a Heading, + is_selected: bool, + is_active: bool, + focused: bool, + caps: Capabilities, +) -> Line<'a> { + let marker = match (is_active, caps.unicode) { + (true, true) => "\u{25b8}", + (true, false) => ">", + (false, _) => " ", + }; + let indent = " ".repeat(usize::from(heading.level.saturating_sub(1)) * INDENT); + + let mut style = Style::default(); + if is_active { + style = style.add_modifier(Modifier::BOLD); + } + if is_selected && focused { + style = style.add_modifier(Modifier::REVERSED); + if caps.color != ColorLevel::None { + style = style.fg(Color::Yellow); + } + } + + Line::from(vec![ + Span::raw(marker), + Span::raw(" "), + Span::styled(format!("{indent}{}", heading.text), style), + ]) +} + +/// Render the sidebar into `area`. +pub fn render( + frame: &mut Frame, + area: Rect, + headings: &[Heading], + selected: usize, + active: Option, + focused: bool, + caps: Capabilities, +) { + let title = if focused { " Outline · [Enter] jump " } else { " Outline · [o] close " }; + let mut block = Block::bordered().title(Line::from(title)); + if focused { + block = block.border_style(Style::default().add_modifier(Modifier::BOLD)); + } + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } + + // An empty panel would look like a bug. Say what happened instead (OXD-032 acceptance). + if headings.is_empty() { + let note = vec![ + Line::raw(""), + Line::from("No headings in").centered(), + Line::from("this document").centered(), + ]; + frame.render_widget( + Paragraph::new(note).style(Style::default().add_modifier(Modifier::DIM)), + inner, + ); + return; + } + + let scroll = usize::from(scroll_for(selected, inner.height, headings.len())); + let lines: Vec = headings + .iter() + .enumerate() + .skip(scroll) + .take(usize::from(inner.height)) + .map(|(i, h)| row(h, i == selected, Some(i) == active, focused, caps)) + .collect(); + + frame.render_widget(Paragraph::new(lines), inner); +} + +#[cfg(test)] +mod tests { + use super::*; + use oxidiris_core::{parser, segment}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + const DOC: &str = "\ +preamble text + +# One + +body of one + +## One point one + +more body + +# Two + +body of two +"; + + fn headings() -> Vec { + parser::parse_markdown(&segment::sanitize(DOC)).headings + } + + #[test] + fn the_tree_comes_from_the_parser_with_its_levels_intact() { + let h = headings(); + assert_eq!(h.len(), 3); + assert_eq!(h[0].text, "One"); + assert_eq!(h[1].level, 2); + assert_eq!(h[2].text, "Two"); + } + + #[test] + fn the_active_heading_is_the_last_one_at_or_before_the_cursor() { + let h = headings(); + assert_eq!(active_index(&h, h[0].block_id), Some(0)); + assert_eq!(active_index(&h, h[1].block_id + 1), Some(1)); + assert_eq!(active_index(&h, 9_999), Some(2)); + } + + /// A document can open with text before its first heading; nothing is active there. + #[test] + fn nothing_is_active_before_the_first_heading() { + let h = headings(); + assert_eq!(active_index(&h, 0), None); + assert_eq!(active_index(&[], 5), None); + } + + #[test] + fn the_selection_scrolls_into_view_and_stops_at_the_end() { + assert_eq!(scroll_for(0, 5, 20), 0); + assert_eq!(scroll_for(4, 5, 20), 0); + assert_eq!(scroll_for(5, 5, 20), 1); + assert_eq!(scroll_for(19, 5, 20), 15); + assert_eq!(scroll_for(3, 10, 4), 0); + } + + fn render_to_string(width: u16, height: u16, headings: &[Heading], selected: usize) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| { + render( + frame, + frame.area(), + headings, + selected, + Some(0), + true, + Capabilities::default(), + ) + }) + .unwrap(); + let buffer = terminal.backend().buffer(); + (0..height) + .map(|y| (0..width).filter_map(|x| buffer.cell((x, y))).map(|c| c.symbol()).collect()) + .collect::>() + .join("\n") + } + + /// Byte offsets lie here: the border and the active marker are both multi-byte. + fn column_of(line: &str, needle: &str) -> usize { + let byte = line.find(needle).unwrap_or_else(|| panic!("{needle:?} not in {line:?}")); + line[..byte].chars().count() + } + + #[test] + fn nested_levels_are_indented() { + let out = render_to_string(30, 8, &headings(), 0); + let child = out.lines().find(|l| l.contains("One point one")).unwrap(); + let parent = out.lines().find(|l| l.contains("One") && !l.contains("point")).unwrap(); + let child_col = column_of(child, "One point one"); + let parent_col = column_of(parent, "One"); + assert!(child_col > parent_col, "H2 must sit further right than H1\n{out}"); + } + + #[test] + fn a_document_without_headings_says_so() { + let out = render_to_string(30, 8, &[], 0); + assert!(out.contains("No headings"), "got {out}"); + } + + #[test] + fn the_active_section_is_marked_with_a_glyph_not_only_a_style() { + let caps = Capabilities { color: ColorLevel::None, unicode: false }; + let h = headings(); + let line = row(&h[0], false, true, false, caps); + let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + assert!(text.starts_with('>'), "expected a marker, got {text:?}"); + } + + #[test] + fn rendering_into_a_tiny_area_does_not_panic() { + for (w, h) in [(3u16, 3u16), (10, 4), (24, 40)] { + render_to_string(w, h, &headings(), 2); + } + } +} diff --git a/crates/oxidiris/src/ui/panel.rs b/crates/oxidiris/src/ui/panel.rs new file mode 100644 index 0000000..c9b37a4 --- /dev/null +++ b/crates/oxidiris/src/ui/panel.rs @@ -0,0 +1,429 @@ +//! Full-text panel: the document as written, with the word being read highlighted. +//! +//! Implements OXD-031. See spec §5. +//! +//! # Why the panel shows the source +//! +//! RSVP removes the page. The reader loses the sense of *where* they are, which is the failure +//! mode spec §0.2 admits to. The panel puts the page back, and the highlight is the thread between +//! the two: it is driven by [`oxidiris_core::Token::byte_span`], which every parser maintains +//! against [`oxidiris_core::Document::source`] for exactly this purpose. +//! +//! Because the spans index the source, the panel renders the source — markup and all, the way +//! `nano` would. Rendering the stripped block text instead would need a second mapping that could +//! disagree with the first, and a highlight that lands on the wrong word is worse than no panel. + +use core::ops::Range; + +use oxidiris_core::segment::{display_width, graphemes}; +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Paragraph}; + +use crate::term::{Capabilities, ColorLevel}; + +/// Soft-wrap `source` to `width` columns, as byte ranges into `source`. +/// +/// Each returned range is a slice of `source` that is safe to render on one row: no newline, and +/// no wider than `width` columns unless a single grapheme is wider on its own. +/// +/// Ranges are not contiguous. The whitespace a line was broken on belongs to neither side, which +/// is what keeps a wrapped line from starting with a stray space. +pub fn wrap(source: &str, width: u16) -> Vec> { + let mut out = Vec::new(); + let mut start = 0usize; + for line in source.split_inclusive('\n') { + let end = start + line.len(); + let text_end = end - usize::from(line.ends_with('\n')); + wrap_one(source, start..text_end, width, &mut out); + start = end; + } + if out.is_empty() { + out.push(0..0); + } + out +} + +/// Break a single hard line into as many display rows as it needs. +fn wrap_one(source: &str, span: Range, width: u16, out: &mut Vec>) { + let text = &source[span.clone()]; + if width == 0 || display_width(text) <= width { + out.push(span); + return; + } + + let base = span.start; + let mut row_start = 0usize; // byte offset of the current row, relative to `text` + let mut used = 0u16; + let mut pos = 0usize; + // Byte offset just past the most recent whitespace run: the preferred place to break. + let mut breakpoint: Option = None; + + for g in graphemes(text) { + let w = display_width(g); + if used + w > width && pos > row_start { + // No whitespace to break on means a URL or a hash: cut mid-word rather than overflow. + let cut = breakpoint.filter(|b| *b > row_start).unwrap_or(pos); + out.push(base + row_start..base + trim_end(text, row_start, cut)); + row_start = cut; + used = display_width(&text[row_start..pos]); + breakpoint = None; + } + if g.chars().all(char::is_whitespace) { + breakpoint = Some(pos + g.len()); + } + used += w; + pos += g.len(); + } + out.push(base + row_start..span.end); +} + +/// Drop trailing whitespace from `text[start..end]`, returning the shortened end offset. +fn trim_end(text: &str, start: usize, end: usize) -> usize { + let slice = &text[start..end]; + start + slice.trim_end().len() +} + +/// Index of the row containing `offset`, or the row just before it. +/// +/// Returns `None` only when there are no rows. The fallback matters because [`wrap`] drops the +/// whitespace it breaks on, so an offset can land in the gap between two rows. +pub fn row_of(rows: &[Range], offset: usize) -> Option { + if rows.is_empty() { + return None; + } + match rows.binary_search_by(|r| { + if offset < r.start { + core::cmp::Ordering::Greater + } else if offset >= r.end { + core::cmp::Ordering::Less + } else { + core::cmp::Ordering::Equal + } + }) { + Ok(idx) => Some(idx), + // `Err(i)` is the insertion point: the row before it is the last one that starts earlier. + Err(i) => Some(i.saturating_sub(1).min(rows.len() - 1)), + } +} + +/// Scroll offset that keeps `row` on screen. +/// +/// The cursor is parked a third of the way down rather than centred, so the reader sees more of +/// what is coming than of what has gone: the panel is for orientation, not for re-reading. +pub fn auto_scroll(row: usize, viewport: u16, total: usize) -> u16 { + let height = usize::from(viewport.max(1)); + let last = total.saturating_sub(height); + row.saturating_sub(height / 3).min(last) as u16 +} + +/// Clamp a manual scroll offset to the document. +pub fn clamp_scroll(scroll: u16, viewport: u16, total: usize) -> u16 { + let last = total.saturating_sub(usize::from(viewport.max(1))); + scroll.min(last as u16) +} + +/// Style for the word currently being read. +/// +/// Reversed video carries the highlight on monochrome terminals, so the panel keeps working under +/// `NO_COLOR` (WCAG SC 1.4.1, spec §3.4.1). Colour is added on top where available. +fn highlight_style(caps: Capabilities) -> Style { + let base = Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD); + match caps.color { + ColorLevel::None => base, + _ => base.fg(Color::Yellow), + } +} + +/// Split one row into styled spans, marking the part covered by `hit`. +fn row_spans<'a>( + source: &'a str, + row: &Range, + hit: Option<&Range>, + caps: Capabilities, +) -> Vec> { + let text = &source[row.clone()]; + let Some(hit) = hit.filter(|h| h.start < row.end && h.end > row.start) else { + return vec![Span::raw(text)]; + }; + + let from = hit.start.max(row.start) - row.start; + let to = (hit.end.min(row.end) - row.start).max(from); + let mut spans = Vec::with_capacity(3); + if from > 0 { + spans.push(Span::raw(&text[..from])); + } + spans.push(Span::styled(&text[from..to], highlight_style(caps))); + if to < text.len() { + spans.push(Span::raw(&text[to..])); + } + spans +} + +/// Everything the panel needs to draw itself. +pub struct View<'a> { + /// The document text the rows index into. + pub source: &'a str, + /// Wrapped rows, from [`wrap`] at this panel's inner width. + pub rows: &'a [Range], + /// Source range of the word being read, if any. + pub highlight: Option>, + /// First visible row. + pub scroll: u16, + /// Whether the panel currently owns the keyboard. + pub focused: bool, + /// What the terminal can render. + pub caps: Capabilities, +} + +/// Render the panel into `area`. +pub fn render(frame: &mut Frame, area: Rect, view: &View<'_>) { + let focused = view.focused; + let title = if focused { " Text · [Tab] back " } else { " Text · [Tab] to scroll " }; + let mut block = Block::bordered().title(Line::from(title)); + if focused { + block = block.border_style(Style::default().add_modifier(Modifier::BOLD)); + } + let inner = block.inner(area); + frame.render_widget(block, area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let lines: Vec = view + .rows + .iter() + .skip(usize::from(view.scroll)) + .take(usize::from(inner.height)) + .map(|row| Line::from(row_spans(view.source, row, view.highlight.as_ref(), view.caps))) + .collect(); + + frame.render_widget(Paragraph::new(lines), inner); +} + +#[cfg(test)] +mod tests { + use super::*; + use oxidiris_core::{PacingMode, Player, parser, segment}; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + /// Every row must be a valid slice of the source it was wrapped from. + fn assert_rows_are_slices(source: &str, rows: &[Range]) { + for row in rows { + assert!(source.get(row.clone()).is_some(), "row {row:?} is not a valid slice"); + assert!(!source[row.clone()].contains('\n'), "row {row:?} contains a newline"); + } + } + + #[test] + fn short_lines_are_returned_whole() { + let source = "one\ntwo\n"; + let rows = wrap(source, 40); + assert_eq!(rows.len(), 2); + assert_eq!(&source[rows[0].clone()], "one"); + assert_eq!(&source[rows[1].clone()], "two"); + } + + #[test] + fn long_lines_break_on_whitespace_and_stay_within_the_width() { + let source = "alpha beta gamma delta epsilon zeta\n"; + let rows = wrap(source, 12); + assert_rows_are_slices(source, &rows); + assert!(rows.len() > 1); + for row in &rows { + assert!( + display_width(&source[row.clone()]) <= 12, + "row {:?} overflows: {:?}", + row, + &source[row.clone()] + ); + assert!(!source[row.clone()].starts_with(' '), "row starts with a stray space"); + assert!(!source[row.clone()].ends_with(' '), "row keeps trailing space"); + } + } + + /// A 40-character git hash has nowhere to break; it must be cut, not allowed to overflow. + #[test] + fn a_word_with_no_break_opportunity_is_cut_mid_word() { + let source = "https://example.com/a/very/long/path/that/never/breaks"; + let rows = wrap(source, 10); + assert_rows_are_slices(source, &rows); + for row in &rows { + assert!(display_width(&source[row.clone()]) <= 10); + } + let rejoined: String = rows.iter().map(|r| &source[r.clone()]).collect(); + assert_eq!(rejoined, source, "cutting mid-word must not lose characters"); + } + + #[test] + fn wide_graphemes_are_measured_in_columns_not_characters() { + let source = "日本語のテキストです"; + let rows = wrap(source, 8); + assert_rows_are_slices(source, &rows); + for row in &rows { + assert!(display_width(&source[row.clone()]) <= 8); + } + } + + #[test] + fn an_empty_document_still_produces_one_row() { + assert_eq!(wrap("", 40), vec![0..0]); + } + + #[test] + fn blank_lines_are_preserved_so_paragraphs_stay_apart() { + let source = "one\n\ntwo\n"; + let rows = wrap(source, 40); + assert_eq!(rows.len(), 3); + assert_eq!(&source[rows[1].clone()], ""); + } + + #[test] + fn zero_width_does_not_loop_forever() { + let rows = wrap("some text here\n", 0); + assert_eq!(rows.len(), 1); + } + + #[test] + fn row_lookup_finds_the_row_holding_an_offset() { + let source = "alpha beta gamma delta epsilon zeta\n"; + let rows = wrap(source, 12); + let offset = source.find("delta").unwrap(); + let row = row_of(&rows, offset).unwrap(); + assert!( + source[rows[row].clone()].contains("delta"), + "got {:?}", + &source[rows[row].clone()] + ); + } + + /// Offsets landing on the whitespace a row was broken at belong to no row; do not panic. + #[test] + fn row_lookup_falls_back_to_the_preceding_row() { + let rows = vec![0..5, 6..11]; + assert_eq!(row_of(&rows, 5), Some(0)); + assert_eq!(row_of(&rows, 100), Some(1)); + assert_eq!(row_of(&[], 0), None); + } + + #[test] + fn auto_scroll_keeps_the_cursor_inside_the_viewport() { + // Near the top there is nothing to scroll to. + assert_eq!(auto_scroll(0, 10, 100), 0); + assert_eq!(auto_scroll(2, 10, 100), 0); + // Mid-document the row sits a third of the way down. + assert_eq!(auto_scroll(50, 12, 100), 46); + // At the end the last screenful is pinned. + assert_eq!(auto_scroll(99, 10, 100), 90); + // A document shorter than the viewport never scrolls. + assert_eq!(auto_scroll(3, 40, 5), 0); + } + + #[test] + fn manual_scroll_cannot_run_past_the_end() { + assert_eq!(clamp_scroll(500, 10, 100), 90); + assert_eq!(clamp_scroll(5, 10, 100), 5); + assert_eq!(clamp_scroll(5, 10, 3), 0); + } + + fn spans_text(spans: &[Span<'_>]) -> String { + spans.iter().map(|s| s.content.as_ref()).collect() + } + + #[test] + fn the_highlight_covers_exactly_the_token_and_nothing_else() { + let source = "alpha beta gamma"; + let row = 0..source.len(); + let hit = 6..10; // "beta" + let spans = row_spans(source, &row, Some(&hit), Capabilities::default()); + assert_eq!(spans_text(&spans), source, "highlighting must not alter the text"); + assert_eq!(spans.len(), 3); + assert_eq!(spans[1].content.as_ref(), "beta"); + assert!(spans[1].style.add_modifier.contains(Modifier::REVERSED)); + assert!(!spans[0].style.add_modifier.contains(Modifier::REVERSED)); + } + + #[test] + fn a_row_without_the_token_is_left_unstyled() { + let source = "alpha beta gamma"; + let spans = row_spans(source, &(0..5), Some(&(6..10)), Capabilities::default()); + assert_eq!(spans.len(), 1); + assert!(!spans[0].style.add_modifier.contains(Modifier::REVERSED)); + } + + /// A token spanning a wrap point must be highlighted on both rows, clipped to each. + #[test] + fn a_token_straddling_a_row_boundary_is_clipped_to_each_row() { + let source = "alpha beta gamma"; + let first = row_spans(source, &(0..8), Some(&(6..10)), Capabilities::default()); + let second = row_spans(source, &(8..16), Some(&(6..10)), Capabilities::default()); + assert_eq!(spans_text(&first), "alpha be"); + assert_eq!(spans_text(&second), "ta gamma"); + } + + /// Under NO_COLOR the highlight has to survive on attributes alone (WCAG SC 1.4.1). + #[test] + fn the_highlight_survives_a_monochrome_terminal() { + let caps = Capabilities { color: ColorLevel::None, unicode: false }; + let spans = row_spans("alpha beta", &(0..10), Some(&(0..5)), caps); + assert!(spans[0].style.add_modifier.contains(Modifier::REVERSED)); + assert_eq!(spans[0].style.fg, None, "no colour may be emitted under NO_COLOR"); + } + + fn render_to_string( + width: u16, + height: u16, + source: &str, + hit: Option>, + ) -> String { + let rows = wrap(source, width - 2); + let view = View { + source, + rows: &rows, + highlight: hit, + scroll: 0, + focused: false, + caps: Capabilities::default(), + }; + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, frame.area(), &view)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..height) + .map(|y| (0..width).filter_map(|x| buffer.cell((x, y))).map(|c| c.symbol()).collect()) + .collect::>() + .join("\n") + } + + #[test] + fn the_panel_draws_the_document_text() { + let out = render_to_string(40, 8, "# Title\n\nSome body text here.\n", None); + assert!(out.contains("# Title"), "got {out}"); + assert!(out.contains("Some body text")); + } + + #[test] + fn the_panel_survives_every_size_it_can_be_given() { + for (w, h) in [(3u16, 3u16), (10, 4), (40, 24), (200, 60)] { + render_to_string(w, h, "a b c\n\nd e f\n", Some(0..1)); + } + } + + /// The highlight must track the player, which is the whole point of the panel. + #[test] + fn the_highlighted_row_follows_the_reading_cursor() { + let text = segment::sanitize( + "First paragraph with several words.\n\nSecond paragraph, further down the page.\n", + ); + let doc = parser::parse_markdown(&text); + let rows = wrap(&doc.source, 30); + let mut player = Player::from_document(&doc, 300, PacingMode::Natural); + + let start_row = row_of(&rows, player.current().unwrap().byte_span.start).unwrap(); + player.goto_end(); + let end_row = row_of(&rows, player.current().unwrap().byte_span.start).unwrap(); + assert!(end_row > start_row, "cursor moved but the panel row did not"); + } +} diff --git a/crates/oxidiris/src/ui/review.rs b/crates/oxidiris/src/ui/review.rs new file mode 100644 index 0000000..7032848 --- /dev/null +++ b/crates/oxidiris/src/ui/review.rs @@ -0,0 +1,133 @@ +//! Review Mode: the paragraph just read, held still. +//! +//! Implements OXD-034. See spec §3.3. +//! +//! RSVP takes away the backward glance — the regression that repairs a misparse, and the reason +//! §0.2 admits comprehension costs. This is the controlled version of it: playback stops, the +//! paragraph is reconstructed verbatim from [`oxidiris_core::Block::byte_span`], and `Esc` puts +//! the reader back exactly where they were. + +use core::ops::Range; + +use ratatui::Frame; +use ratatui::layout::Rect; +use ratatui::text::Line; +use ratatui::widgets::{Block, Clear, Paragraph}; + +use crate::ui::panel::wrap; + +/// Fraction of the screen the popup occupies, in eighths. +const WIDTH_EIGHTHS: u16 = 6; +/// Fraction of the screen height the popup occupies, in eighths. +const HEIGHT_EIGHTHS: u16 = 5; + +/// Centre a `width` x `height` rectangle inside `area`, shrinking to fit if needed. +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let w = width.min(area.width); + let h = height.min(area.height); + Rect { + x: area.x + (area.width.saturating_sub(w)) / 2, + y: area.y + (area.height.saturating_sub(h)) / 2, + width: w, + height: h, + } +} + +/// Render the paragraph at `span` as a static overlay over `area`. +pub fn render(frame: &mut Frame, area: Rect, source: &str, span: Range, scroll: u16) { + let popup = + centered(area, area.width * WIDTH_EIGHTHS / 8, (area.height * HEIGHT_EIGHTHS / 8).max(3)); + frame.render_widget(Clear, popup); + + let block = Block::bordered().title(Line::from(" Review · [Esc] resume ")); + let inner = block.inner(popup); + frame.render_widget(block, popup); + if inner.width == 0 || inner.height == 0 { + return; + } + + // The source is shown as written, markup and all: Review Mode exists to answer "what did that + // actually say", and a stripped copy is not the same answer (OXD-034 acceptance). + let text = source.get(span).unwrap_or(""); + let rows = wrap(text, inner.width); + let lines: Vec = rows + .iter() + .skip(usize::from(scroll)) + .take(usize::from(inner.height)) + .map(|r| Line::raw(&text[r.clone()])) + .collect(); + + frame.render_widget(Paragraph::new(lines), inner); +} + +/// Number of wrapped rows the paragraph needs, used to clamp scrolling. +pub fn row_count(source: &str, span: Range, width: u16) -> usize { + source.get(span).map_or(0, |text| wrap(text, width).len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + const SOURCE: &str = "\ +# Heading + +A paragraph with `inline code` and a [link](https://example.com) in it. + +Another paragraph. +"; + + fn render_to_string(width: u16, height: u16, span: Range, scroll: u16) -> String { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, frame.area(), SOURCE, span.clone(), scroll)).unwrap(); + let buffer = terminal.backend().buffer(); + (0..height) + .map(|y| (0..width).filter_map(|x| buffer.cell((x, y))).map(|c| c.symbol()).collect()) + .collect::>() + .join("\n") + } + + /// The point of Review Mode is the original text, including the markup the stream strips. + #[test] + fn the_paragraph_is_shown_exactly_as_written() { + let start = SOURCE.find("A paragraph").unwrap(); + let end = SOURCE.find("in it.").unwrap() + "in it.".len(); + let out = render_to_string(80, 20, start..end, 0); + assert!(out.contains("`inline code`"), "markup was stripped: {out}"); + assert!(out.contains("https://example.com"), "the link target was dropped: {out}"); + } + + #[test] + fn a_long_paragraph_can_be_scrolled() { + let start = SOURCE.find("A paragraph").unwrap(); + let end = SOURCE.find("in it.").unwrap() + "in it.".len(); + let top = render_to_string(30, 8, start..end, 0); + let scrolled = render_to_string(30, 8, start..end, 2); + assert_ne!(top, scrolled, "scrolling must move the text"); + } + + #[test] + fn row_count_grows_as_the_popup_narrows() { + let span = 0..SOURCE.len(); + assert!(row_count(SOURCE, span.clone(), 20) > row_count(SOURCE, span, 200)); + } + + #[test] + fn an_out_of_range_span_renders_empty_rather_than_panicking() { + let out = render_to_string(40, 10, 9_000..9_100, 0); + assert!(out.contains("Review"), "the frame should still be drawn"); + assert_eq!(row_count(SOURCE, 9_000..9_100, 40), 0); + } + + #[test] + fn the_popup_stays_inside_a_small_frame() { + for (w, h) in [(4u16, 3u16), (20, 6), (200, 60)] { + render_to_string(w, h, 0..20, 0); + } + let popup = centered(Rect { x: 0, y: 0, width: 10, height: 4 }, 40, 30); + assert_eq!(popup.width, 10); + assert_eq!(popup.height, 4); + } +} diff --git a/crates/oxidiris/src/ui/status.rs b/crates/oxidiris/src/ui/status.rs index a9f6799..28ecb06 100644 --- a/crates/oxidiris/src/ui/status.rs +++ b/crates/oxidiris/src/ui/status.rs @@ -10,7 +10,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph}; -use crate::keymap::{Action, keys_for}; +use crate::keymap::{Action, Mode, keys_for}; use crate::term::{Capabilities, ColorLevel}; /// Left half of the status line: configured speed and the speed actually achieved. @@ -42,11 +42,40 @@ pub fn progress_bar(ratio: f64, width: u16, unicode: bool) -> String { bar } +/// Key hints for `mode`. +/// +/// The hints are generated from the binding table, so a key that means something different here +/// is advertised differently here (spec §7.1). +pub fn hint_text(player: &Player, mode: Mode) -> String { + if mode.is_panel() { + return format!( + "{} [{}] scroll [{}] back [{}] help [{}] quit", + mode.label(), + keys_for(Action::ScrollDown), + keys_for(Action::FocusPanel), + keys_for(Action::ToggleHelp), + keys_for(Action::Quit), + ); + } + // Deliberately no hint for Tab or o here: the line already fills an 80-column terminal, and + // both panels advertise themselves in their own title bar. + let state = if player.is_playing() { "pause" } else { "play" }; + format!( + "[{}] {state} [{}] speed [{}] seek [{}] help [{}] quit", + keys_for(Action::TogglePlay), + keys_for(Action::Faster), + keys_for(Action::Back), + keys_for(Action::ToggleHelp), + keys_for(Action::Quit), + ) +} + /// Render the status block into `area`. pub fn render( frame: &mut Frame, area: Rect, player: &Player, + mode: Mode, caps: Capabilities, message: Option<&str>, ) { @@ -80,20 +109,10 @@ pub fn render( let hint = match message { Some(msg) => msg.to_string(), - None if warn => { + None if warn && mode == Mode::Reader => { format!("high speed (>{FLASH_WARNING_WPM} WPM) may be uncomfortable · [?] help") } - None => { - let state = if player.is_playing() { "pause" } else { "play" }; - format!( - "[{}] {state} [{}] speed [{}] seek [{}] help [{}] quit", - keys_for(Action::TogglePlay), - keys_for(Action::Faster), - keys_for(Action::Back), - keys_for(Action::ToggleHelp), - keys_for(Action::Quit), - ) - } + None => hint_text(player, mode), }; let bar_width = inner.width.saturating_sub(hint.chars().count() as u16 + 2); @@ -175,7 +194,9 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(80, 6)).unwrap(); let p = player(); terminal - .draw(|frame| render(frame, frame.area(), &p, Capabilities::default(), None)) + .draw(|frame| { + render(frame, frame.area(), &p, Mode::Reader, Capabilities::default(), None) + }) .unwrap(); let buffer = terminal.backend().buffer(); @@ -188,13 +209,37 @@ mod tests { assert!(rendered.contains("450 WPM"), "status line was {rendered:?}"); } + /// A reader who has focused the panel needs to know J/K no longer touch the speed. + #[test] + fn the_hint_line_changes_with_the_mode() { + let p = player(); + let reading = hint_text(&p, Mode::Reader); + let browsing = hint_text(&p, Mode::Browser); + assert!(reading.contains("speed")); + assert!(!browsing.contains("speed"), "got {browsing:?}"); + assert!(browsing.contains("BROWSER")); + assert!(browsing.contains("scroll")); + } + + /// OXD-024: the line must not wrap on the narrowest supported terminal. + #[test] + fn every_mode_hint_fits_an_eighty_column_terminal() { + let p = player(); + for mode in [Mode::Reader, Mode::Browser, Mode::Outline] { + let hint = hint_text(&p, mode); + assert!(hint.chars().count() <= 78, "{mode:?} hint is {} chars", hint.chars().count()); + } + } + #[test] fn a_high_speed_earns_a_warning_line() { let mut p = player(); p.set_wpm(900); let mut terminal = Terminal::new(TestBackend::new(100, 6)).unwrap(); terminal - .draw(|frame| render(frame, frame.area(), &p, Capabilities::default(), None)) + .draw(|frame| { + render(frame, frame.area(), &p, Mode::Reader, Capabilities::default(), None) + }) .unwrap(); let buffer = terminal.backend().buffer(); let line: String = @@ -208,7 +253,9 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(100, 6)).unwrap(); let p = player(); terminal - .draw(|frame| render(frame, frame.area(), &p, Capabilities::default(), None)) + .draw(|frame| { + render(frame, frame.area(), &p, Mode::Reader, Capabilities::default(), None) + }) .unwrap(); let buffer = terminal.backend().buffer(); let line: String = @@ -222,7 +269,9 @@ mod tests { let mut terminal = Terminal::new(TestBackend::new(12, 3)).unwrap(); let p = player(); terminal - .draw(|frame| render(frame, frame.area(), &p, Capabilities::default(), None)) + .draw(|frame| { + render(frame, frame.area(), &p, Mode::Reader, Capabilities::default(), None) + }) .unwrap(); } } diff --git a/docs/decisions/split-view-layout.md b/docs/decisions/split-view-layout.md new file mode 100644 index 0000000..610585b --- /dev/null +++ b/docs/decisions/split-view-layout.md @@ -0,0 +1,68 @@ +# ADR 002 — The status block stays full width, and the panel is a width decision + +**Status:** Accepted +**Date:** 2026-09-02 +**Task:** OXD-031, OXD-032 +**Amends:** `docs/informations/proposals.md` §5, §3.4.4 + +## Context + +The spec's §5 mockup draws two columns, and puts the status block *inside* the left one: + +```text +┌─────── FOCUS HERE ─────────┐┌────────────── README.md ──────────────┐ +│ ▼ ││ ... │ +├────────────────────────────┤│ │ +│ speed: 450 WPM (eff. 361) ││ ... │ +│ word: 45/999 (42%) ││ │ +└────────────────────────────┘└───────────────────────────────────────┘ +``` + +Two things did not survive contact with the implementation. + +**The status block does not fit in half a window.** At the 80-column minimum the left column is 40 +wide, 38 inside the border. `speed: 450 WPM (eff. 361)` is 25 columns and `word: 45/999 (42%)` is +18; stacked they fit, but the key hint line — 69 columns, generated from the binding table so it +cannot silently disagree with the dispatcher — does not, and neither does the progress bar beside +it. Something would have to be clipped on every frame at the size we promise to support. + +**Terminal *height* was deciding whether the panel appeared.** `size_class` in `term.rs` folds +width and height into one verdict, and `Full` needs 80x24. A 100x20 window is plenty wide for two +columns; it was losing the panel over four missing rows. Spec §3.4.4 only ever talks about +columns: *"Khi terminal < 80 cột: bỏ panel phải"*. + +## Decision + +**The status block spans the full window width, below both columns.** The reader frame and the +text panel split the space above it. + +**Panel and outline visibility are decided on width alone** — `width >= 80`, plus the existing +`TooSmall` floor that stops anything being drawn at all. Height no longer takes the panel away. + +**Below 80 columns the outline is drawn as an overlay** rather than a sidebar column. There is no +room for a third column there, and navigating is a moment of its own: playback is already paused +while the outline has focus, so covering the reader frame costs nothing. + +## Consequences + +**Good.** Every status field stays legible at 80 columns, which is the size the accessibility +notes in §3.4 commit to. The panel is available on short-but-wide windows — a `tmux` pane above a +shell is exactly that shape. One layout function, `ui::panes`, is the single source of truth for +where everything goes, and it is a pure function of the terminal rectangle, so the event loop can +ask it for the panel width *before* drawing and re-wrap the document only when that width changed. + +**Cost.** The screen is less symmetric than the mockup. The status block is a band across the +bottom rather than a footer under the reader. + +**Watch out.** `ui::panes` and `App::relayout` must agree; they do because the latter calls the +former. If a future pane is added, add it there and nowhere else. + +## Alternatives rejected + +- **Stack the status fields vertically inside the left column,** as the mockup does. Rejected: it + costs four rows of the reader frame at exactly the sizes where rows are scarcest, and the key + hints still have nowhere to go. +- **Shorten the status text when narrow.** Rejected: the effective-WPM number exists precisely + because a reader who sees only the configured number believes a speed they are not reading at + (§3.2.4). Dropping it when the window is small drops it for the readers most likely to be on a + small window. \ No newline at end of file diff --git a/docs/informations/proposals.md b/docs/informations/proposals.md index f9682d9..00c845d 100644 --- a/docs/informations/proposals.md +++ b/docs/informations/proposals.md @@ -486,6 +486,22 @@ terminal không phải TTY (khi đó nên tự chuyển sang `--dump`). từ chứ không phải dòng. * Khi terminal < 80 cột: bỏ panel phải, tự chuyển `focus` mode (Chương 3.4.4). +> **Đính chính (v0.2.0).** Khi hiện thực OXD-031, hai điểm trong mockup trên đã phải đổi. +> +> 1. **Khối trạng thái chạy hết chiều ngang cửa sổ**, nằm dưới cả hai cột, chứ không nằm trong cột +> trái như hình vẽ. Ở mức tối thiểu 80 cột thì cột trái chỉ rộng 38 ô bên trong khung — không đủ +> cho dòng gợi ý phím (69 ô, sinh ra từ bảng keymap) lẫn thanh tiến trình. +> 2. **Việc ẩn/hiện panel chỉ căn cứ vào chiều rộng**, đúng như câu trên viết, chứ không căn cứ vào +> `size_class` (vốn gộp cả chiều cao). Cửa sổ 100x20 vẫn đủ rộng cho hai cột. +> +> Ngoài ra, dưới 80 cột thì sidebar Outline (Chương 3.3) được vẽ đè lên khung đọc thay vì thành một +> cột riêng: không còn chỗ cho cột thứ ba. Chi tiết và lý do trong +> [`docs/decisions/split-view-layout.md`](../decisions/split-view-layout.md) (ADR 002). +> +> Điểm mockup vẽ đúng và đã giữ nguyên: panel phải hiển thị **văn bản nguồn kèm markup** +> (`## Tính năng chính`), không phải văn bản đã bóc markup. Vì `byte_span` trỏ vào `Document::source`, +> render đúng nguồn là cách duy nhất để highlight không bao giờ lệch từ. + --- ## 6. Hệ thống Lệnh đầu vào (CLI Flags & Options)