Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
fc83306a81 feat(keymap,editor): add Ctrl-d/u half-page scroll
Decode Ctrl+D/Ctrl+U as HalfPageDown/HalfPageUp intents (matching the existing
DeleteWord/DeleteLine chord pattern, so the editor stays ignorant of Ctrl).
They step display (soft-wrapped) rows, not logical lines, so half a page is
half the visible window regardless of how prose wraps: Normal moves the caret
and the viewport follows, View scrolls the viewport, Insert/Command are no-ops.
2026-07-11 18:32:05 +02:00
Julien Calixte
491dc57144 docs: drop relative line numbering from the v0.2 gutter
Relative numbering renumbers the whole gutter on every j/k, a tall partial
refresh per cursor move that eats the e-ink ghosting budget for no
proportionate gain. Gutter is now absolute-only; Spike 13 downgrades from a
genuine e-ink risk to a layout decision.
2026-07-11 18:31:33 +02:00
4 changed files with 165 additions and 25 deletions

View File

@@ -49,7 +49,7 @@ name = "v0.2 navigation"
start = 2026-06-29
original = 2026-07-20
status = "on-track"
note = "Core work landed early: motions and modes already run; gutter, Ctrl-d/u, UTF-8 buffer remain."
note = "Core work landed early: motions and modes already run; Ctrl-d/u and the UTF-8 buffer landed 2026-07-11; only the line-number gutter remains."
[[feature]]
name = "v0.2.5 international input"
@@ -190,16 +190,22 @@ Out of scope: Vim, palette, multiple files, branches, conflict handling.
## v0.2 — Vim navigation — [~]
**Status:** navigation done in core; the **UTF-8-correct buffer landed
2026-07-11** (hardware-verified). Remaining = `Ctrl-d/u` and the line-number
gutter. Shipped early beyond scope: a read-only **View** mode and the full
`d`/`c` operator + text-object grammar (see v0.3 / v0.4).
2026-07-11** (hardware-verified) and **`Ctrl-d/u` half-page scroll landed
2026-07-11** (host-tested). Remaining = the line-number gutter. Shipped early
beyond scope: a read-only **View** mode and the full `d`/`c` operator +
text-object grammar (see v0.3 / v0.4).
- [x] Mode state machine (Normal / Insert / View), mode indicator in the status strip
- [~] Movement: `h j k l`, `w b e`, `0 $`, `gg G` (✓); `Ctrl-d Ctrl-u` remain
- [x] Movement: `h j k l`, `w b e`, `0 $`, `gg G`, `Ctrl-d Ctrl-u`. `Ctrl-d/u`
step **display** (soft-wrapped) rows, not logical lines — half a page is
half the visible window however prose wraps; decoded as `HalfPageDown/Up`
intents in the keymap, caret moves and the viewport follows.
- [x] `i a o O A` to enter Insert
- [x] `Esc` returns to Normal
- [ ] Line numbers in the left gutter: relative in Normal mode (current line
shown as its absolute number), absolute in Insert mode — Spike 13 first
- [ ] Line numbers in the left gutter: **absolute** — Spike 13 first. Relative
numbering was dropped (2026-07-11): renumbering the whole gutter on every
`j`/`k` burns the e-ink ghosting budget for no proportionate gain, whereas
absolute renumbers only the rows below an edit.
- [x] Groundwork — UTF-8-correct buffer: caret motions and edits step by
character, not byte (dropped the ASCII == byte-offset assumption), so every
motion stays correct with accented input. **Done 2026-07-11** alongside

View File

@@ -114,23 +114,20 @@ risk early.
a design decision this spike hands data to.)
13. **Spike 13 — Line-number gutter.** Draw a fixed-width digit column left of
the text area. The v0.2 spec is the hard case: **relative** numbers in
Normal mode (current line as its absolute number), **absolute** in Insert.
The naive part — reserving columns and drawing digits — is trivial; the
e-ink risk is churn. Relative numbering renumbers the *entire visible gutter
on every `j`/`k`*, so a single cursor move becomes a partial refresh of a
tall digit column, straight into the Spike 5 windowed-Y path and the "20
partials → forced full refresh" ghosting counter
([`firmware/src/epd.rs`](../firmware/src/epd.rs), render module). Absolute
numbering is cheap by comparison — only the rows below an inserted/deleted
line renumber. Two more interactions: the gutter steals horizontal columns
from the render-time soft-wrap, and wrapped continuation rows have no number
(blank vs. tilde — a layout decision). Prove: measure gutter partial-refresh
cost for (a) a held-`j` scan that renumbers the whole relative gutter vs.
(b) a single-line absolute edit, and confirm neither blows the ghosting
budget or forces extra full refreshes. Decides whether relative numbering is
viable on this panel or must be gated (absolute-only, or a batched/coalesced
gutter repaint). (Feeds v0.2's line-number gutter; genuine new e-ink risk.)
the text area. **Absolute numbering only**relative numbering was dropped
(2026-07-11). It renumbered the *entire visible gutter on every `j`/`k`*, so
a single cursor move became a partial refresh of a tall digit column,
straight into the Spike 5 windowed-Y path and the "20 partials → forced full
refresh" ghosting counter ([`firmware/src/epd.rs`](../firmware/src/epd.rs),
render module) — real e-ink cost for no proportionate gain on a
distraction-first panel. Dropping it removes the genuine e-ink risk this
spike existed to prove: absolute numbering is cheap — only the rows below an
inserted/deleted line renumber. What remains is a **layout decision**, not a
latency one: the gutter steals horizontal columns from the render-time
soft-wrap, and wrapped continuation rows have no number (blank vs. tilde).
Prove: reserve the gutter width, then confirm a single-line edit repaints
only the rows at/below the change and forces no extra full refresh. (Feeds
v0.2's line-number gutter; now low-risk after the relative drop.)
14. **Spike 14 — Multi-file navigation (open / switch / new / delete).** The
panel *mechanism* is already Spike 11 (which names this v0.5 file palette),

View File

@@ -34,6 +34,10 @@ const WRITE_COLS: usize = 60;
/// Visible writing rows. 13 × 20 px = 260 px; the bottom 12 px is the transient
/// `:` command line (the only thing left of the old status band).
const ROWS: usize = (HEIGHT / 20) as usize; // 13
/// Half-page scroll distance for `Ctrl-d`/`Ctrl-u`, in **display rows** — vim's
/// `'scroll'` default (half the visible window). Fixed, not configurable: a
/// resizable `'scroll'` is meaningless on a fixed 13-row panel.
const HALF_PAGE: usize = ROWS / 2; // 6
/// x of the 1 px rule dividing writing column from side panel, and the left edge
/// of panel text (a small gutter past the rule).
const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 600
@@ -230,6 +234,10 @@ impl Editor {
Key::Backspace => self.backspace(),
Key::DeleteWord => self.delete_word_before(),
Key::DeleteLine => self.delete_to_line_start(),
// Half-page scroll is a navigation gesture — Normal/View only. In
// Insert it's a no-op rather than yanking the caret off the text
// you're typing.
Key::HalfPageDown | Key::HalfPageUp => {}
Key::Escape => {
self.mode = Mode::Normal;
// vim drops the caret onto the last inserted char.
@@ -245,7 +253,20 @@ impl Editor {
fn normal_key(&mut self, key: Key) {
let c = match key {
Key::Char(c) => c,
// Esc and non-character events cancel any pending command.
// Ctrl-d/u: scroll half a screen by *display* rows (see
// `move_display_rows`). Like any non-motion key, they abandon a
// pending count/operator first.
Key::HalfPageDown => {
self.reset_pending();
self.move_display_rows(HALF_PAGE as isize);
return;
}
Key::HalfPageUp => {
self.reset_pending();
self.move_display_rows(-(HALF_PAGE as isize));
return;
}
// Esc and other non-character events cancel any pending command.
_ => {
self.reset_pending();
return;
@@ -468,6 +489,10 @@ impl Editor {
Key::Char('j') => self.scroll_top += 1, // clamped in draw()
Key::Char('k') => self.scroll_top = self.scroll_top.saturating_sub(1),
Key::Char(' ') => self.scroll_top += ROWS,
// Half-page scroll, mirroring Normal mode — here it's a pure
// viewport move (View has no caret to chase). Clamped in draw().
Key::HalfPageDown => self.scroll_top += HALF_PAGE,
Key::HalfPageUp => self.scroll_top = self.scroll_top.saturating_sub(HALF_PAGE),
Key::Char('G') => {
let total = self.layout().len();
self.scroll_top = total.saturating_sub(ROWS);
@@ -576,6 +601,25 @@ impl Editor {
self.caret = self.advance_chars(prev_start, col, prev_end);
}
/// Move the caret by `delta` **display** (soft-wrapped) rows, keeping the
/// column where the target row is long enough. This is the `Ctrl-d`/`Ctrl-u`
/// step: unlike `j`/`k` (which move by *logical* line and so jump over
/// wrapped continuation rows), it walks the rendered layout, so half a page
/// is half the visible window no matter how the prose wraps. In Normal mode
/// the caret is always kept on-screen, so moving it *is* the scroll — the
/// viewport follows via `adjust_scroll` at draw time.
fn move_display_rows(&mut self, delta: isize) {
let lay = self.layout();
if lay.is_empty() {
return;
}
let (row, col) = self.caret_rc(&lay);
let target = (row as isize + delta).clamp(0, lay.len() as isize - 1) as usize;
let line = &lay[target];
let row_end = line.start + line.text.len();
self.caret = self.advance_chars(line.start, col, row_end);
}
/// Start of the next whitespace-delimited word after `from`.
fn word_forward_pos(&self, from: usize) -> usize {
let b = self.text.as_bytes();
@@ -1540,6 +1584,93 @@ mod tests {
assert_eq!(e.mode(), Mode::Normal);
}
// ---- Ctrl-d / Ctrl-u half-page scroll (v0.2) ----
/// The core reason this isn't `HALF_PAGE × move_down`: on one long paragraph
/// that soft-wraps, half-page-down steps *display* rows, advancing the caret
/// half a window into the wrap — whereas `j` (logical-line) can't move
/// within the single line at all.
#[test]
fn half_page_down_steps_display_rows_within_a_wrapped_line() {
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 10)); // 10 wrapped rows
e.caret = 0;
e.handle(Key::HalfPageDown);
assert_eq!(e.caret, WRITE_COLS * HALF_PAGE); // down HALF_PAGE display rows
// Contrast: `j` on the same single logical line is a no-op.
let mut j = Editor::with_text("a".repeat(WRITE_COLS * 10));
j.caret = 0;
j.handle(Key::Char('j'));
assert_eq!(j.caret, 0);
}
/// Up is the inverse of down within a wrapped line.
#[test]
fn half_page_up_is_the_inverse_within_a_wrapped_line() {
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 10));
e.caret = WRITE_COLS * HALF_PAGE;
e.handle(Key::HalfPageUp);
assert_eq!(e.caret, 0);
}
/// Clamps at both ends: up from the top stays; down past the bottom lands on
/// the last row on a character boundary, never out of range.
#[test]
fn half_page_clamps_at_both_ends() {
let mut e = Editor::with_text("a".repeat(WRITE_COLS * 3)); // 3 rows
e.caret = 0;
e.handle(Key::HalfPageUp);
assert_eq!(e.caret, 0);
e.handle(Key::HalfPageDown);
e.handle(Key::HalfPageDown);
assert!(e.caret <= e.text.len());
assert!(e.text.is_char_boundary(e.caret));
}
/// The viewport follows the caret past the window: after enough half-pages,
/// `scroll_top` advances (in draw) and the caret stays visible.
#[test]
fn half_page_down_scrolls_the_viewport() {
let text = vec!["a"; 40].join("\n"); // 40 one-char lines = 40 display rows
let mut e = Editor::with_text(text);
e.caret = 0;
for _ in 0..4 {
e.handle(Key::HalfPageDown);
}
e.draw(true); // adjust_scroll runs here
assert!(e.scroll_top() > 0, "viewport should have scrolled");
let lay = e.layout();
let (row, _) = e.caret_rc(&lay);
assert!(row >= e.scroll_top() && row < e.scroll_top() + ROWS);
}
/// In View mode (read-only) half-page moves the viewport directly and leaves
/// the caret alone.
#[test]
fn half_page_scrolls_viewport_in_view_mode() {
let mut e = Editor::with_text(vec!["a"; 40].join("\n"));
let caret_before = e.caret;
e.handle(Key::Char('v')); // Normal -> View
assert_eq!(e.mode(), Mode::View);
e.handle(Key::HalfPageDown);
assert_eq!(e.scroll_top(), HALF_PAGE);
assert_eq!(e.caret, caret_before); // caret untouched in View
e.handle(Key::HalfPageUp);
assert_eq!(e.scroll_top(), 0);
}
/// Inert in Insert mode — it must not yank the caret off the text you're
/// typing.
#[test]
fn half_page_is_a_noop_in_insert_mode() {
let mut e = Editor::with_text(vec!["a"; 40].join("\n"));
e.caret = 0;
e.handle(Key::Char('i')); // Normal -> Insert
e.handle(Key::HalfPageDown);
assert_eq!(e.caret, 0);
assert_eq!(e.mode(), Mode::Insert);
}
#[test]
fn text_getter_reflects_edits() {
let e = typed("hello");

View File

@@ -30,6 +30,10 @@ pub enum Key {
DeleteWord,
/// Cmd/GUI+Backspace — delete back to the start of the current line.
DeleteLine,
/// Ctrl+D — scroll down half a screen (vim `Ctrl-d`).
HalfPageDown,
/// Ctrl+U — scroll up half a screen (vim `Ctrl-u`).
HalfPageUp,
/// Caps Lock tapped on its own. A no-op for now; groundwork for a future
/// vim-style normal mode.
Escape,
@@ -140,6 +144,8 @@ fn translate(usage: u8, shift: bool, ctrl: bool, cmd: bool) -> Option<Key> {
});
}
0x1a if ctrl => return Some(Key::DeleteWord), // Ctrl+W, readline-style
0x07 if ctrl => return Some(Key::HalfPageDown), // Ctrl+D, half-page down
0x18 if ctrl => return Some(Key::HalfPageUp), // Ctrl+U, half-page up
_ => {}
}