Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
203 changes: 116 additions & 87 deletions BACKLOG.md

Large diffs are not rendered by default.

38 changes: 36 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ encoding_rs = "0.8"

# --- dev ---
proptest = "1"
insta = "1"

[profile.release]
lto = "thin"
Expand Down
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
65 changes: 54 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ░░░░░░░│
└──────────────────────────────────────────────────────────────────────────────┘
```
Expand All @@ -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

Expand All @@ -48,13 +66,20 @@ 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
```

### 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 |
Expand All @@ -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:
Expand All @@ -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.

Expand All @@ -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

Expand Down
49 changes: 49 additions & 0 deletions crates/oxidiris-core/src/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion crates/oxidiris/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
anyhow.workspace = true

[dev-dependencies]
insta.workspace = true
Loading
Loading