From fc83306a8163345b460a659167c52d3f9d158ecf Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 11 Jul 2026 18:32:05 +0200 Subject: [PATCH] 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. --- docs/roadmap.md | 14 +++-- editor/src/lib.rs | 133 +++++++++++++++++++++++++++++++++++++++++++++- keymap/src/lib.rs | 6 +++ 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 78aa0da..55b9995 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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,12 +190,16 @@ 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: **absolute** — Spike 13 first. Relative diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 9157caa..9f1f470 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -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"); diff --git a/keymap/src/lib.rs b/keymap/src/lib.rs index e69525e..832c09f 100644 --- a/keymap/src/lib.rs +++ b/keymap/src/lib.rs @@ -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 { }); } 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 _ => {} }