# Completed Work
## Task 1a — Window, raw input capture, Markdown file append
Stood up the runnable, borderless, dark, keyboard-only egui/eframe (wgpu backend) app.
- `src/main.rs` — builds `NativeOptions` with a borderless, titlebar-less, fullscreen
`ViewportBuilder` and calls `eframe::run_native`, returning `eframe::Result`.
- `src/app.rs` — `WriteApp` implementing `eframe::App` via the 0.34 `ui(&mut self, ui, frame)`
signature:
- On launch, creates `dirs::document_dir()/write/<epoch>.md` (falling back to `home_dir`),
opened append-only and wrapped in a `BufWriter`.
- Captures raw input each frame from `ctx.input(|i| i.events)`: `Event::Text` appended verbatim;
Enter → idempotent `"\n\n"`; Backspace → idempotent single space (never deletes); Esc → quit;
Tab focus navigation suppressed; arrows/Delete/Home/End/PageUp-Down and `command`-modified
combos (paste/undo) ignored.
- Durability: flush + `File::sync_data()` every ~30s (wall-clock `Instant`) and a final fsync on
clean exit via `on_exit`. `request_repaint_after(30s)` keeps the idle fsync firing.
- Renders a full-rect black background plus a single wrapped galley of the session buffer
(`Painter::layout` → `Painter::galley`); `clear_color` overridden to opaque black.
Notes / deviations from the original design sketch:
- `WriteApp::new` returns `Box<dyn Error + Send + Sync>` (eframe's `AppCreator` requires the error
to be `Send + Sync`).
- Text layout uses `Painter::layout(&self, ...)` instead of `ui.fonts(|f| f.layout(...))`: in
egui 0.34 the `fonts` reader yields an immutable `&FontsView` whereas `FontsView::layout` needs
`&mut self`; `Painter::layout` wraps the mutable access internally.
- Used `ctx.content_rect()` (the non-deprecated replacement for `screen_rect()`).
`cargo clippy` and `cargo fmt --check` are clean. No automated tests (GUI loop); the 1a acceptance
criteria are manual.
## Task 1b — The fading 30-second view
Replaced 1a's plain solid-gray render with the real focus view: only the last ~30s of typed text is
visible, fading linearly to fully transparent over ~1s once it crosses the threshold, never
reappearing. Purely visual — the Markdown file and append behavior from 1a are untouched.
- `src/app/render.rs` (new, pure/unit-tested per the `module.rs` + `module/submodule.rs` layout) —
`Glyph { ch, birth }`; `fade_factor(age)` (opaque ≤30s, linear over the 1s fade, 0 past 31s);
`quantize_alpha(factor)` → `u8`; `runs()` coalesces adjacent same-alpha glyphs into
`(String, u8)` runs; `build_job()` builds a word-wrapped `LayoutJob` with one `TextFormat` section
per run, fading via `Color32::gamma_multiply` (composite-over-black opacity, not fade-to-gray).
- `src/app.rs` — added `mod render;`, a `visible: VecDeque<Glyph>` render buffer fed by `append`
(now `append(&mut self, s, now)`), with `now = ctx.input(|i| i.time)` stamped per frame. Each
frame prunes fully-faded glyphs from the front, then draws a centered, bottom-anchored galley so
newest text sits near the bottom and older lines scroll off the top. `document` is retained for
1c's word count.
- CPU idle fix: repaint is now conditional — `request_repaint()` only while glyphs are visible;
when blank, wake only at the next 30s fsync deadline
(`request_repaint_after(SYNC_INTERVAL.saturating_sub(last_sync.elapsed()))`), replacing 1a's
unconditional `request_repaint_after(SYNC_INTERVAL)`.
Notes / deviations:
- Layout goes through `Painter::layout_job(&self, ...)` rather than `ui.fonts(|f| f.layout_job(..))`:
in egui 0.34 the `fonts` reader yields an immutable `&FontsView` but `FontsView::layout_job` needs
`&mut self`; the painter wraps the mutable access internally (same pattern as 1a's `layout`).
`cargo clippy`, `cargo fmt`, and the 4 unit tests in `render.rs` (`cargo nextest run`) are clean.
The fade/scroll visuals remain manual acceptance.
## Task 1c — Explicit visible-buffer clears + Esc menu
Added the writer's manual "wipe the screen" controls and a key-driven Esc menu, without ever
removing anything already written to the file (clearing is visual only).
- `src/app/render.rs` — added `clear_factor(age_since_clear)` (immediate linear fade over
`FADE_SECS`, no 30s hold) and `glyph_factor(birth, now, visibility_head)` which combines a glyph's
own age-fade with the clear-fade (glyphs born at/after `visibility_head` are unaffected). `runs`
and `build_job` now take `visibility_head` and use `glyph_factor`. New unit tests:
`clear_factor_immediate`, `glyph_factor_unaffected_after_head`, `glyph_factor_clears_older_glyphs`.
- `src/app.rs` — added `visibility_head: f64`, replaced `last_was_enter: bool` with an `enter_run: u8`
state machine (0 → none, 1 → one Enter written `\n\n`, 2+ → cleared/no-op), and added
`menu_open: bool`. Triggers that advance `visibility_head` to "now" (simultaneous ~1s fade-out):
Backspace (first of a run; also appends one idempotent space) and a consecutive second Enter (no
extra newlines). The event loop gates on `menu_open` so the menu is key-driven (Q quits, Esc
resumes) and typing into the void is ignored. Esc opens the menu and fsyncs. Prune now uses
`glyph_factor` so clear-faded glyphs drain in birth order. A centered, title-bar-less
`egui::Window` overlays a live `split_whitespace().count()` word count plus key hints.
All v1 tasks (1a/1b/1c) complete. `cargo clippy`, `cargo nextest run` (7 tests), and
`cargo fmt --check` all clean.
## Cursor-wiggle / visual feedback on backspace-clear
On the first backspace of a run, the visible text now performs a brief, decaying horizontal "shake"
(the classic macOS "no" wiggle) while it fades out, giving immediate kinetic acknowledgment that the
no-delete/wipe gesture was registered. Purely visual — the Markdown file, append behavior, and clear
logic are untouched. Double-Enter clears stay shake-free.
- `src/app/render.rs` — added `SHAKE_SECS`/`SHAKE_AMPLITUDE`/`SHAKE_FREQ` constants and a pure
`shake_offset(since)`: a sine oscillation (12px peak, ~3 oscillations over 0.4s) under a
linear-decay envelope, returning 0.0 at rest (outside the `(0, SHAKE_SECS)` window). New unit
tests `shake_offset_at_rest_outside_window` and `shake_offset_bounded_and_decays`.
- `src/app.rs` — added `shake_start: f64` (init `f64::NEG_INFINITY` so early frames report at-rest),
set to `now` in the same first-backspace-of-a-run guard that already sets `visibility_head`, and
offset the galley's x-position by `shake_offset(now - shake_start)`. The offset (≤12px) stays well
inside the 40px margin, so text never clips; the existing post-backspace fade repaint loop already
covers the 0.4s shake.
`cargo clippy`, `cargo nextest run` (9 tests), and `cargo fmt --check` all clean.
## Keyboard shortcut to return to windowed (non-fullscreen) mode
Gave the writer a deliberate, non-colliding way to drop the borderless-fullscreen app back to a
windowed (maximized, chrome-hidden) shell so the window can be moved/resized/closed via the OS.
Driven from the Esc menu (the existing key-driven control surface where writing input is already
gated off), so it can never fire during normal prose entry.
- `src/app.rs` — added a `fullscreen: bool` live-state field (init `false`, set `true` in the
launch-time deferred-entry block alongside `Fullscreen(true)`). `entered_fullscreen` stays a
**distinct** one-shot deferral latch so a later manual exit to windowed never re-fires the
deferral. New `egui::Key::W` arm in the menu key handler sends
`ViewportCommand::Fullscreen(!self.fullscreen)`, flips the bool, and closes the menu to resume
writing (eframe 0.34 has no toggle variant, so we track the state ourselves). The menu hint label
is now state-aware: `"W — windowed"` while fullscreen, `"W — fullscreen"` while windowed.
Toggle is reachable only via the Esc menu; typing "w" mid-sentence still produces a literal "w".
GUI window-state, so manual acceptance per project convention. `cargo clippy`, `cargo nextest run`
(9 tests), and `cargo fmt --check` all clean.
## Anchor the text column to the vertical center
Moved the visible galley from a bottom-margin anchor to a center-relative one so the active writing
line holds a fixed focal point near the window's vertical center while older text scrolls upward.
- `src/app.rs` — hoisted the proportional font size into `const FONT_SIZE: f32 = 28.0;` and reused it
for both the `egui::FontId` and the x-height calc (no duplicated literal). Replaced
`y = rect.bottom() - margin - galley.size().y` with a center-relative anchor:
`x_height = 0.5 * FONT_SIZE`, `center_y = rect.center().y`,
`y = center_y + x_height - galley.size().y`, pinning the galley's bottom edge one x-height below
the window center. The galley grows upward as lines accumulate; no upward clamp (the 30s fade
bounds total text). Horizontal layout, `col_width`/`max_col`, and the backspace `shake_offset` dx
are untouched; `margin` still drives the horizontal column-width calc only.
GUI positioning, so manual acceptance per project convention. `cargo fmt --check`, `cargo clippy`,
and `cargo nextest run` (9 render tests) all clean.
## Persistent word-count indicator (upper-right HUD)
Added an always-visible, small dim-gray **day-total** word count anchored to the upper-right corner,
seeded from the daily file's existing on-disk content so prior same-day sessions are included (the
in-memory `document` mirror still starts empty each launch). It's a write-time HUD, not a read mode.
- `src/app/render.rs` — added a pure `word_count(s: &str)` helper (`split_whitespace().count()`)
shared by the HUD and the Esc menu so the two always agree and match the on-disk seed. New unit
test `word_count_counts_whitespace_tokens` (the plan's expected value for the multi-whitespace
case was off-by-one — that string has 4 tokens, not 3; the test asserts the correct 4).
- `src/app.rs` — `open_session_file` now returns `Option<(File, usize)>`, computing the seed via
`word_count(&fs::read_to_string(&dir).unwrap_or_default())` right after the lock succeeds (a read
error degrades to 0). Added a `seed_words: usize` field, threaded through `WriteApp::new`. After
the main galley paints, the HUD draws `seed_words + word_count(&document)` as
`format!("{words} words")` via `Painter::text` with `Align2::RIGHT_TOP` at `0.5 * FONT_SIZE` in
`Color32::from_gray(110)`, only when `words >= 2`. The Esc-menu count uses the same expression.
- `src/main.rs` — destructures the new `(file, seed_words)` tuple and passes `seed_words` to
`WriteApp::new`.
The count is monotonic (clears never shrink `document`); no repaint changes needed (it only moves on
input). GUI placement is manual acceptance. `cargo fmt --check`, `cargo clippy`, and
`cargo nextest run` (10 tests) all clean.
## Task 2 — Bounded backspace via file truncation
Softened the "no deletion ever" policy into a strictly bounded real deletion: `Backspace` now
truncates the day file by one whole char, but only within the last two whitespace-delimited words
and never below a monotonic floor that ratchets forward as the writer types. Once text is 2+ words
in the past it is permanently committed. The old append-a-space/clear/shake machinery is removed
from the handler (shake constants/field retained for sub-task 2.1).
- `src/app/render.rs` — added `start_byte_of_second_to_last_word(s)`, a single-pass
`char_indices` scan returning the byte offset of `words[len-2]` (or `0` for <2 words), matching
`split_whitespace` semantics. `Glyph` gained an `end_offset: usize` (byte end-of-char within
`document` at append time) so the visible buffer truncates correctly. New unit tests:
`start_byte_of_second_to_last_word_cases` (ASCII, leading/trailing whitespace, `\n\n`, multi-byte
`"café déjà vu"` → byte 6) and `delete_floor_ratchet_is_monotonic` (folds the ratchet over a
growing/shrinking doc sequence, asserting it never decreases and stays `>= boot_size`).
- `src/app.rs` — `open_session_file` now also returns `boot_size = file.metadata()?.len()` (return
type aliased as `SessionFile = (File, usize, u64)` to keep clippy's `type_complexity` quiet).
`WriteApp` gained `boot_size`/`delete_floor` (both init to `boot_size`) and dropped
`last_was_backspace`. `append` computes per-glyph `end_offset` and ratchets
`delete_floor = delete_floor.max(boot_size + start_byte_of_second_to_last_word(&document))`. New
`fn backspace(&mut self)`: no-op when at/under the floor, else `document.pop()`, flush (mandatory
before truncate — append-mode `BufWriter`), `set_len(boot_size + document.len())`, prune tail
glyphs whose `end_offset > new doc len`, reset `enter_run`. The Backspace arm now just calls
`self.backspace()`; the dead `last_was_backspace = false` assignments were removed from the Text
and Enter arms.
- `src/main.rs` — destructures the `(file, seed_words, boot_size)` triple and threads `boot_size`
into `WriteApp::new`.
- `README.md` — softened the intro/closing prose and rewrote the `Backspace` key-table row to
describe the bounded delete.
`shake_start` field and `shake_offset` render call left in place (dx stays 0) for sub-task 2.1.
`cargo fmt`, `cargo clippy --all-targets`, and `cargo nextest run` (12 tests) all clean.
## Sub-task 2.1 — Wiggle when the backspace limit is hit
Reconnected the existing shake machinery (freed up by Task 2's bounded backspace) to give the writer
a visual "no" cue when a backspace is blocked, replacing the previous silent no-op.
- `src/app.rs` — `backspace` regained its `now: f64` param. Both blocked early-returns (document
empty / at the `delete_floor`, and the would-cross-the-floor guard) now set `self.shake_start =
now` before returning, driving the pre-existing `shake_offset(now - shake_start)` galley dx into a
brief decaying horizontal wiggle. A *successful* deletion sets nothing, so it stays shake-free. The
Backspace event arm now calls `self.backspace(now)`, and the stale "sub-task 2.1 will add the
wiggle" comments were refreshed to describe the implemented behavior.
No new animation code and no new tests: the wiggle is a single field write feeding the already
unit-tested `shake_offset` math (`shake_offset_at_rest_outside_window`,
`shake_offset_bounded_and_decays`), and the render loop already requests continuous repaints while
text is visible so the 0.4s shake animates. `cargo clippy`, `cargo fmt`, and `cargo nextest run`
(12 tests) all clean.
## Tint the still-deletable tail ("fresh" text) toward red
Gave the writer a visual signal of how far back a backspace can reach: the deletable tail (document
bytes `>= delete_floor - boot_size`) now renders with a warmer/redder tint while locked, permanent
text keeps the normal gray body color. The boundary follows the monotonic `delete_floor` (not a
freshly recomputed second-to-last word), so the colored region matches exactly what a subsequent
backspace can remove.
- `src/app/render.rs` — added `FRESH_GB_SCALE` (0.7) and a `redden(Color32)` helper that keeps the
red channel and attenuates green/blue. `runs` gained a `floor_byte` param and now splits on
`(alpha, fresh)`, returning a `fresh` flag per run (`fresh = end_offset - ch.len_utf8() >=
floor_byte`, saturating to avoid underflow). `build_job` gained a trailing `floor_byte` param and
picks `redden(base)` vs `base` per run *before* `gamma_multiply`, so the tint composes with the
age-fade (premultiplied-alpha path drains to black) and darker mode.
- `src/app.rs` — `ui` computes `floor_byte = (delete_floor - boot_size) as usize` (subtraction can't
underflow: `delete_floor` is initialized to `boot_size` and only ratcheted up) and threads it into
`build_job`.
Updated `runs_coalesce_same_age` / `runs_split_on_alpha_change` for the new tuple/signature and
added `runs_split_on_fresh_boundary` asserting the split at the floor boundary. `cargo fmt`,
`cargo clippy --all-targets`, and `cargo nextest run` (13 tests) all clean.
## Cursor — solid non-blinking rectangle
Added a passive insertion-point marker: a solid, filled, non-blinking rectangle that always sits at
the tail of the live text, exactly where `append` drops the next glyph.
**This is not a violation of the project's "no cursor movement" rule.** The marker never moves under
user command — there are no arrows, no click-to-position, and no selection. It is purely passive,
riding the end of the current text. The distinction matters: "no cursor movement" forbids the writer
from repositioning an insertion point, not the existence of a marker that follows the tail.
- `src/app.rs` — pure paint inside `WriteApp::ui`, in the gap around the existing
`painter.galley(...)` call. The tail rect is computed *before* that call (because `painter.galley`
takes `Arc<Galley>` by value and moves it): `galley.pos_from_cursor(galley.end())` gives a
zero-width galley-local rect, offset by the galley paint origin `pos2(x, y)`. Width is
`0.5 * FONT_SIZE`; height is `tail.height().max(FONT_SIZE)` (floored so it's never degenerate). The
rect is only computed when `self.visible` is non-empty, so nothing draws on a blank/cleared/
fully-faded screen. After the galley move, the rect is painted with `painter.rect_filled` using
`body.gamma_multiply(factor)`, where `factor = glyph_factor(g.birth, now, self.visibility_head)`
for the newest visible glyph (`self.visible.back()`) — so the cursor fades in lockstep with the
text it sits against and inherits `darker` mode via `body`.
Because `x` already folds in the shake `dx`, the marker rides the backspace-limit wiggle for free.
No new struct fields, no changes to `render.rs`/`main.rs`/the event loop, and no repaint-loop change
(the render loop already requests continuous repaints while `visible` is non-empty). Consistent with
the GUI-only precedent, no new unit tests were added (this is pure paint with no testable logic);
positioning was confirmed by manual acceptance. `cargo fmt --check`, `cargo clippy --all-targets`,
and `cargo nextest run` (13 tests) all clean.
## `--edit` flag — open `$EDITOR` on today's file
Added the program's first CLI flag, `--edit`, an escape hatch to revisit and freely edit
today's session file outside the fading TUI.
- `src/app.rs` — extracted the inline date/dir path logic out of `open_session_file()` into a
reusable `pub fn today_file_path()` (resolves `~/Documents/write/YYYY-MM-DD.md`, `create_dir_all`
on `write/`, no open/lock); `open_session_file()` now calls it. Added `pub fn run_edit()`: touches
the file so the editor opens an existing path, resolves the editor via `$VISUAL` → `$EDITOR` → `vi`,
whitespace-splits the command (so `"code -w"` works), spawns with inherited stdio, waits, and
propagates a non-zero editor exit code.
- `src/main.rs` — before acquiring the session file/lock, if `--edit` is in args, runs `run_edit()`
and returns; never builds `NativeOptions`, never runs `run_native`, never takes the lock.
Dependency-free arg matching via `std::env::args()`. Manually verified: `EDITOR=true ... --edit`
exits 0, `EDITOR=false ... --edit` exits 1; plain `cargo run` launches the TUI unchanged.
`cargo fmt --check`, `cargo clippy --all-targets`, and `cargo nextest run` (13 tests) all clean.
## Session timestamp header — `## <datetime>` at session start
Each real writing session now stamps today's append-only file with a Markdown header
`## <datetime>` so sessions are timestamped and visually delimited within the day's file.
- `src/app.rs` — in the `Ok(())` lock arm of `open_session_file()`: bind `existing =
fs::read_to_string(&path)` once, compute `seed = word_count(&existing)` **before** writing the
header (so the header's tokens aren't counted as the writer's), then write the header to the raw
append-mode fd **before** capturing `boot_size`. Capturing `boot_size` after the write folds the
header into the hard delete floor, so the existing floor mechanism protects it from backspace for
free, and the empty in-memory `document` mirror means it is never rendered or faded on screen.
- Idempotent blank-line separation: the newline prefix caps at exactly two trailing newlines
(`""` for empty or already-`"\n\n"`-terminated, `"\n"` for one trailing newline, else `"\n\n"`),
so repeated same-day launches don't grow blank-line runs.
- Format `%Y-%m-%d %-I:%M:%S%P %Z` → e.g. `## 2026-06-26 2:30:05pm PDT`. `--edit` does not call
`open_session_file()`, so editing in `$EDITOR` injects no header.
Manual verification (GUI task), consistent with prior color/cursor work. `cargo clippy`,
`cargo fmt --check`, and `cargo nextest run` (13 tests) all clean. Known follow-up filed in PLAN.md:
prior-session headers inflate later sessions' day-total word count by ~4 tokens each until
`word_count` learns to skip ATX `## ` header lines.
## Header-aware word counting — skip `## ` ATX-header lines in `word_count`
`word_count` (`src/app/render.rs`) now drops ATX level-2 header lines (first non-space content
`## `) before tallying whitespace tokens. Session timestamp headers (`## <datetime>`, written by
`open_session_file`) previously inflated the writer's day-total by ~4 words per prior session,
because `seed = word_count(&existing)` ran over earlier sessions' header lines. The filter is
applied centrally in `word_count`, so the HUD, the Esc menu, and the on-disk seed stay consistent
and exclude both prior- and current-session headers. Level-3+ headers (`### …`) are unaffected.
Added `word_count_skips_atx_headers` unit test; `cargo fmt`, `cargo clippy`, and `cargo nextest run`
(14 tests) all clean.
## Invert the color dependency — primary green, older text a darker green
The text-color model now runs **primary → older** instead of primary → fresh. The light green is
the primary color: freshly-typed (still-backspaceable) text renders at `PRIMARY = rgb(154, 220,
154)` directly, and older/locked text is *derived* as a uniformly darkened shade via
`darken(primary)` (`OLDER_SCALE = 0.78` → `rgb(120, 171, 120)`), preserving the green hue. No pure
white anywhere; aging text now transitions green → darker-green, then still fades to black over
`FADE_SECS` on the premultiplied `gamma_multiply` path.
- `src/app/render.rs` — replaced the stale `redden`/`FRESH_SCALE` machinery (which mis-named green
as "red") with `PRIMARY`, `PRIMARY_DARKER = rgb(21, 30, 21)`, `OLDER_SCALE`, and a `darken` fn
that scales all three channels. In `build_job`, renamed the `base` param to `primary` and inverted
the per-run choice to `if fresh { primary } else { darken(primary) }`.
- `src/app.rs` — replaced the near-white `body` computation with
`let primary = if self.darker { PRIMARY_DARKER } else { PRIMARY };`, renamed `body` → `primary`
at the `build_job` arg, the `painter.galley` fallback, and the cursor color; imported `PRIMARY`
and `PRIMARY_DARKER` (`darken` stays internal to `render.rs`).
Darker mode now reads as a dim green (primary `rgb(21, 30, 21)`, older `≈ rgb(16, 23, 16)`) rather
than gray. Manual verification (GUI task); no test asserts RGB. `cargo fmt --check`, `cargo clippy`,
and `cargo nextest run` (14 tests) all clean.
## clap-based CLI — `edit` and `show` subcommands (replacing `--edit`)
Replaced the hand-rolled `std::env::args().skip(1).any(|a| a == "--edit")` check with a real
[`clap`](https://docs.rs/clap) derive CLI, and restructured the non-default modes as subcommands.
`write` (no args) launches the fading TUI exactly as before; subcommands run without taking the
advisory lock or flashing a window.
- `Cargo.toml` — added `clap = { version = "4", features = ["derive"] }` via
`cargo add clap --features derive` (clap 4.6.1).
- `src/app.rs` — added `pub fn run_show()` next to `run_edit()`: resolves `today_file_path()` and
`fs::read` → `stdout().write_all` (byte-verbatim, not `read_to_string`/`println!`, so headers and
trailing-newline state are preserved). A missing file (`ErrorKind::NotFound`) is `Ok(())` with no
output. Creates nothing and takes no lock.
- `src/main.rs` — added a clap `Cli`/`Command` derive (`#[command(version, about)]`) with an
optional subcommand. `main()` parses and dispatches `Edit`/`Show` before any lock/window work
(each returns `Ok(())` or `std::process::exit` to satisfy the `eframe::Result` return type);
`None` falls through to the unchanged `open_session_file()` → `run_native` path. The `--edit`
flag is **removed** — `write --edit` now errors via clap (exit 2).
- `README.md` — documented `write edit` / `write show` and that a bare `write` launches the TUI.
- `CHANGELOG.md` — added an `[Unreleased]` section (clap CLI, `edit`/`show` subcommands, `--edit`
removed).
Verified: `--help` lists `edit`/`show`, `--edit` errors with exit 2, `write show` is byte-identical
to `cat ~/Documents/write/$(date +%F).md` and prints nothing/exit 0 with no file. `cargo fmt`,
`cargo clippy --all-targets`, `cargo build`, and `cargo nextest run` (14 tests) all clean.
## Add `\` (backslash) menu leader key plus a `\\` literal-backslash action
Made `\` a second way to open the menu (alongside `Esc`), giving a leader-style command prefix
(`\q` quit, `\w` windowed/fullscreen, `\d` darker mode), and added a menu action that inserts a
single literal backslash so the new leader key remains typeable as text.
- `src/app.rs` — the only behavioral change:
- Writing-mode key match: `egui::Key::Escape | egui::Key::Backslash` now opens the menu (still
`self.fsync()` on open). The trailing `Text("\\")` from the opening keypress is swallowed by
the existing `if self.menu_open { ... continue; }` gate, so the opening `\` never leaks.
- Menu-open key match: new `egui::Key::Backslash` arm appends one `"\\"`, resets `enter_run = 0`
(matching the normal Text-append path), and sets the deferred `close_menu = true`. Because the
close is deferred, `menu_open` stays true through the rest of the batch and the trailing
`Text("\\")` is swallowed — exactly one backslash is inserted.
- `egui::Key::Escape => close_menu = true` retained as the in-menu resume key.
- Menu hint label gained ` \ — backslash`.
- Struct-field comments reworded from "toggled from the Esc menu (W)/(D)" to "toggled from the
menu (W)/(D)".
- `src/app/render.rs` — `word_count` doc comment "the HUD and the Esc menu" → "the HUD and the menu".
- `README.md` — keybinding table row now shows `` `\` `` / `` `Esc` `` as openers and documents the
literal-backslash action.
- `CHANGELOG.md` — added an `[Unreleased]` section above the released `0.4.0` describing the new
leader key and `\\` action.
Verified: `cargo fmt`, `cargo clippy --all-targets`, `cargo build`, and `cargo nextest run`
(14 tests) all clean. Event-handling behavior is not unit-testable without a harness (the suite
covers pure helpers only); it relies on egui's established `Key`-before-`Text` ordering, the same
assumption already underpinning the `W`/`D` menu keys.
## Menu layout — larger font + two-column command/keybinding table
Replaced the in-app menu's single run-on hint line with a tinted two-column table.
- `src/app.rs` (menu block, `if self.menu_open { egui::Window::new("menu") ... }`):
- Header `"{words} words"` now tinted `PRIMARY` (green) via `RichText::new(...).color(PRIMARY)`;
size unchanged at `24.0`.
- The run-on `win_hint`/`dark_hint` label is gone. Commands now render as an
`egui::Grid::new("menu_commands")` two-column table: command name (left), key binding
(right-aligned via `Layout::right_to_left`). Rows: quit/Q, resume/Esc, fullscreen-or-windowed/W,
darker-or-normal/D, backslash/\.
- Command/key rows bumped 25% to `.size(17.5)` (14 × 1.25) and tinted `PRIMARY`.
- State-aware rows: `win_cmd`/`dark_cmd` carry the flipping word (windowed↔fullscreen,
normal↔darker) in the command column; the key column is the static `W`/`D`.
Presentation-only change — no behavior/state changes. `PRIMARY` was already imported in `app.rs`.
Verified: `cargo fmt`, `cargo clippy --all-targets`, `cargo build`, and `cargo nextest run`
(14 tests) all clean. Menu rendering is not unit-testable without an egui harness; layout
correctness confirmed by manual smoke test only (the suite covers pure helpers).
## Debounce the backspace-blocked wiggle
A blocked backspace at the delete floor triggers the horizontal "no" wiggle via
`shake_start`. Previously this fired on *every* blocked press, so holding/mashing
backspace at the floor restarted the animation each frame and it never decayed.
- `src/app/render.rs` — added pure predicate `shake_ready(now, shake_start) -> bool`
next to `shake_offset`/`SHAKE_SECS`: returns `now - shake_start >= SHAKE_SECS`. The
`f64::NEG_INFINITY` sentinel makes the very first wiggle always fire.
- `src/app.rs` — added `fn start_shake(&mut self, now)` that sets `shake_start` only
when `shake_ready` is true; routed both blocked branches in `backspace()` through it
instead of assigning `shake_start` directly. Imported `shake_ready` from `render`.
- Boundary is `>=`, matching `shake_offset`'s `since >= SHAKE_SECS` rest condition, so a
fresh wiggle can fire exactly when the prior one reaches rest. Successful deletions are
unaffected (they never call `start_shake`).
Verified: `cargo clippy --all-targets` and `cargo nextest run` (15 tests, including the
new `shake_ready_debounces_until_completion`) all clean.