From 284ddd3713d6cbee01591712136501ce38485cf3 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Fri, 16 Oct 2026 10:25:27 +0200 Subject: [PATCH] feat: configure half-page viewport scroll distance --- TODO.md | 4 +++- docs/architecture.md | 2 ++ docs/configuration.md | 5 ++++- docs/editing.md | 2 +- examples/config.rb | 1 + src/editor.rs | 14 ++++++++++-- src/main.rs | 2 +- src/ruby.rs | 51 ++++++++++++++++++++++++++++++++++++++++++- src/ruby_config.rb | 8 +++++++ src/session.rs | 1 + src/tui.rs | 13 +++++++++++ 11 files changed, 96 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index 97adff4..546c18a 100644 --- a/TODO.md +++ b/TODO.md @@ -11,7 +11,9 @@ - [x] diff view: annotate folder with checkmark if all items under given folder has been checked AND the folder is collapsed. - [x] diff view: automatically detect changes and update the view when relevant bookmarks move change ids (regards bookmark-based diff view; i.e., user selected at least one bookmark in the before/after picker) - [x] action to scroll viewport without having to move the cursor (like vim's `ctrl+e|u`). -- [ ] scroll viewport action (`ctrl+u|d`) scroll distance should be configurable. +- [x] scroll viewport action (`ctrl+u|d`) scroll distance should be configurable. +- [ ] diff view: labels are currently attached also when scrolling the view port horizontally (such that e.g. a function name is not fully visible in the viewport), which is good, but it currently attaches at that line, thus pushing down the function by one line, which causes a one-line jump when scrolling horizontally. + - we should in this case attach the label above the function instead of on the same line that it's defined such that it's not pushed down. - [ ] diff view: github integration (code review) - add comments on a pr, add review comments at specific lines of files, request changes, approve pr, etc. - [ ] diff view: local review comments (same features as for github reviews) that allows review of local changes to deliver specific reviews to ai agents used for coding. - design question: would this require a server that the agent can interact with, or would local review files be fine? don't want to use server client stuff, if it would be easier and better to just use local files. diff --git a/docs/architecture.md b/docs/architecture.md index 118b32e..ba1a87b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -84,6 +84,8 @@ Resolution exposes its projected comparison through the same active DiffState an `Editor::scroll_distance()` supplies the session-wide optional edge margin. The TUI applies it to document/diff visual rows and unwrapped text columns, and retains independent picker offsets across navigation frames. Picker windows exclude their fixed chrome and align whole multi-row entries. File projections carry a monotonic viewport revision, so catalog/filter/expansion changes invalidate picker identity without hashing the entire catalog on every redraw. Metadata-only status changes do not move the viewport. Explicit LSP/wrap-toggle anchors and detached wheel views retain precedence. +Keyboard viewport actions queue per-view row and half-page counts without moving carets. `Editor::take_viewport_scroll(height)` resolves half-page counts using the session-wide `scroll_half_page_lines` preference, or half the focused viewport when unset, and saturates multiplication before the frontend clamps movement to content bounds. Ordinary, diff, and conflict-history rendering share this resolution. + Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling. Already-ready mouse reports share a redraw: at most 64 reports are applied in their original order before painting, without dropping or summing away wheel steps. Readiness checks do not wait. The first non-mouse event is retained for the next iteration after rendering, so keys, paste, and resizes cannot reuse geometry from before their layout changes. The batch bound keeps continuous mouse input from starving drawing or background polling. diff --git a/docs/configuration.md b/docs/configuration.md index 391218f..9256443 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,6 +16,7 @@ Vamp.configure do |config| config.git_blame = true # false disables current-line attribution config.git_gutter = "bar" # or "symbols", "line_numbers", "none" config.scroll_distance = 0 # nonnegative edge distance, or nil to center + config.scroll_half_page_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count config.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" } config.whitespace_scope = "all" # or "leading" for indentation only @@ -57,6 +58,8 @@ Plugin paths resolve relative to the config file, not the shell's working direct The setting applies to ordinary and historical buffers, split/unified diff panes, file pickers and explorers, command/buffer palettes, completion lists, and revision selectors. Wrapped text measures visual rows, not source lines; picker distances use result rows, excluding borders, search fields, and help. Manual mouse-wheel scrolling remains detached until keyboard navigation resumes. Only `nil` or integers from `0` through the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems) are accepted; other values reject the configuration. +`config.scroll_half_page_lines` controls Normal/Visual Ctrl+u/Ctrl+d (`core.scroll_half_up` / `core.scroll_half_down`). The default `nil` scrolls half the focused text viewport, rounded down with a minimum of one screen row. A positive integer, such as `config.scroll_half_page_lines = 3`, fixes the distance in screen rows, including wrapped continuations and diff display rows. Counts multiply that distance; `2` then Ctrl+d scrolls six rows with this example. This session-wide setting applies to ordinary, historical, diff, and conflict text panes without moving carets or selections. It does not change the cursor-edge margin, one-row scroll actions, mouse wheel, or picker navigation. Only `nil` or integers from `1` through the platform's Rust `usize` maximum are accepted; other values reject the configuration. Distances and count multiplication saturate at Rust's `isize` limit before the viewport clamps to content bounds. + `config.highlight_trailing_whitespace` defaults to `false`. Set it to `true` to give trailing whitespace a red background in ordinary and historical buffers and both sides of split/unified diffs, including wrapped and horizontally scrolled text. Spaces, expanded tab cells, and Unicode whitespace at the end of a source line are highlighted; whitespace-only lines are included, but empty lines and padding beyond the text are not. Wrapping does not make interior whitespace trailing. Search, selections, and secondary carets retain their background precedence; diagnostic underlines remain visible. This changes display only, not file contents. Only literal Ruby booleans are accepted; other values reject the configuration. `config.whitespace_characters` defaults to `{}` (disabled). Assign a hash mapping literal whitespace characters to dim display markers, for example `{ " " => "·", "\t" => "→", "\u00a0" => "⍽", "\n" => "↵" }` for spaces, tabs, nonbreaking spaces, and line breaks. Unlisted characters keep their ordinary appearance. Keys and values must be single-character strings; keys must be whitespace with visible terminal width, tabs, or `"\n"`, and values must be printable one-cell characters. Carriage returns and other control characters, zero-width markers, and wide markers reject the configuration. Only indicator cells receive terminal dim styling; source-text colors, diagnostic underlines, and highlighted backgrounds remain intact. @@ -208,7 +211,7 @@ Normal `C` / `D` / `Y` change/delete/copy the cursor-to-line-end suffix through `f` / `F` seeks forward/backward on the current line in Normal and Visual, with counts such as `2fx`. Rebind `config.core.seek_forward` / `config.core.seek_backward` to a single key or leader sequence; the action then waits for a literal target character. For example, `config.bind "s", config.core.seek_forward` makes `2sx` seek the second following `x`. Pass `mode: config.modes.visual` to install a Visual route. These native-only actions do not have Ex commands. -Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_half_up` / `config.core.scroll_half_down` without moving carets or selection endpoints. Counts multiply the half-viewport distance. Bind `config.core.scroll_up` / `config.core.scroll_down` for one screen row per count, for example `config.bind "ctrl+e", config.core.scroll_down` to replace clone-below in Normal. Pass `mode: config.modes.visual` to rebind Visual too. These actions work in ordinary and diff text panes, use wrapped screen rows, and have no Ex commands. Cursor navigation or editing resumes caret following. +Normal and Visual Ctrl+u/Ctrl+d invoke `config.core.scroll_half_up` / `config.core.scroll_half_down` without moving carets or selection endpoints. Counts multiply the configured `scroll_half_page_lines` distance (half a viewport by default). Bind `config.core.scroll_up` / `config.core.scroll_down` for one screen row per count, for example `config.bind "ctrl+e", config.core.scroll_down` to replace clone-below in Normal. Pass `mode: config.modes.visual` to rebind Visual too. These actions work in ordinary and diff text panes, use wrapped screen rows, and have no Ex commands. Cursor navigation or editing resumes caret following. ## Action reference diff --git a/docs/editing.md b/docs/editing.md index a372307..f2bf635 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -31,7 +31,7 @@ Indicators refresh asynchronously. Typing and undo/redo retain the last complete | Normal, Visual | `g` jumps immediately to that one-based source line at first nonblank (`42g`); clamps at EOF and extends Visual selections | | Normal | Ctrl+o goes to an older jump location; Ctrl+i/Tab goes forward; counts select multiple locations | | Normal, Visual | Decimal prefixes repeat cursor/word/search motions: `5j`, `3k`, `2w`, `4e`, `2n`; Esc cancels a pending count | -| Normal, Visual | Ctrl+u/Ctrl+d scroll half a viewport up/down without moving carets or selections; counts multiply the distance | +| Normal, Visual | Ctrl+u/Ctrl+d scroll up/down without moving carets or selections; half a viewport by default, configurable with `config.scroll_half_page_lines`; counts multiply the distance | | Normal | `J` (Shift+j) joins each targeted line with the next line | | Normal | `x`/Delete delete; `u` undo; `U` (Shift+u) redo; `:` enter command mode | | Normal | `yy` copies whole lines without moving; `dd` deletes them; `cc` changes them and enters Insert; counts work (`3yy`, `2dd`, `2cc`) | diff --git a/examples/config.rb b/examples/config.rb index f278dde..219bf51 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -6,6 +6,7 @@ Vamp.configure do |config| config.clipboard_mode = "system" # "separate" keeps ordinary y/d/c/p/P internal config.git_blame = true # false disables current-line inline blame config.scroll_distance = 0 # nonnegative edge distance, or nil to center + config.scroll_half_page_lines = nil # Ctrl+u/d: half viewport, or a positive screen-row count config.highlight_trailing_whitespace = false # true marks line-end whitespace in red config.whitespace_characters = {} # opt in: { " " => "·", "\t" => "→", "\n" => "↵" } config.whitespace_scope = "all" # or "leading" for indentation only; newline markers remain independent diff --git a/src/editor.rs b/src/editor.rs index 432947e..ea2804d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -13,7 +13,7 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::fmt; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; -use std::num::NonZeroU8; +use std::num::{NonZeroU8, NonZeroUsize}; use std::ops::Range; #[cfg(unix)] use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; @@ -2009,6 +2009,7 @@ pub struct Editor { popup: PopupConfig, labels: BTreeMap, scroll_distance: Option, + scroll_half_page_lines: Option, highlight_trailing_whitespace: bool, whitespace_characters: BTreeMap, whitespace_scope: WhitespaceScope, @@ -2119,6 +2120,7 @@ impl Editor { popup: PopupConfig::default(), labels: BTreeMap::new(), scroll_distance: Some(0), + scroll_half_page_lines: None, highlight_trailing_whitespace: false, whitespace_characters: BTreeMap::new(), whitespace_scope: WhitespaceScope::All, @@ -4116,6 +4118,10 @@ impl Editor { self.scroll_distance = distance; } + pub(crate) fn set_scroll_half_page_lines(&mut self, lines: Option) { + self.scroll_half_page_lines = lines; + } + pub(crate) fn highlight_trailing_whitespace(&self) -> bool { self.highlight_trailing_whitespace } @@ -5829,7 +5835,11 @@ impl Editor { pub(crate) fn take_viewport_scroll(&self, height: usize) -> isize { let (rows, halves) = self.active.view.viewport_scroll.take(); - rows.saturating_add(halves.saturating_mul((height / 2).max(1) as isize)) + let lines = self + .scroll_half_page_lines + .map_or((height / 2).max(1), NonZeroUsize::get); + let lines = isize::try_from(lines).unwrap_or(isize::MAX); + rows.saturating_add(halves.saturating_mul(lines)) } fn reveal_caret(&mut self) { diff --git a/src/main.rs b/src/main.rs index 1ab9f00..c211d3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,7 @@ Normal: i/a insert, I/A insert at first nonblank/end, o/O new line _ splits above/below, | left/right; Option/Alt+h/j/k/l focuses neighboring panes Ctrl+Option/Alt+h/l previous/next tab; :tab.new opens current buffer in another tab Normal/Visual: prefix motions with counts (5j, 2w, 3n); Esc cancels count - Normal/Visual: Ctrl+u/d scroll half a viewport up/down without moving carets; counts supported + Normal/Visual: Ctrl+u/d scroll up/down without moving carets; configurable distance (default half viewport), counts supported u undo, U redo, : command, / search, * search word, n/N next/previous match yy copies whole lines; dd deletes them; cc changes them and enters Insert; counts: 3yy, 2dd, 2cc dw/db cut forward/backward words on the current line; cw/cb cut the same spans and enter Insert diff --git a/src/ruby.rs b/src/ruby.rs index 867582e..e35113a 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -3,7 +3,7 @@ use crate::editor::{Context, Effect, Feature}; use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect}; use serde::{Deserialize, Serialize}; use std::io::{self, BufRead, BufReader, Write}; -use std::num::NonZeroU8; +use std::num::{NonZeroU8, NonZeroUsize}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender}; @@ -46,6 +46,7 @@ pub struct Config { pub popup: PopupConfig, pub labels: BTreeMap, pub scroll_distance: Option, + pub scroll_half_page_lines: Option, pub highlight_trailing_whitespace: bool, pub whitespace_characters: BTreeMap, pub whitespace_scope: crate::editor::WhitespaceScope, @@ -97,6 +98,7 @@ impl Default for Config { popup: PopupConfig::default(), labels: BTreeMap::new(), scroll_distance: Some(0), + scroll_half_page_lines: None, highlight_trailing_whitespace: false, whitespace_characters: BTreeMap::new(), whitespace_scope: crate::editor::WhitespaceScope::All, @@ -135,6 +137,7 @@ pub fn load_config(path: &Path) -> Result { popup, labels, scroll_distance, + scroll_half_page_lines, highlight_trailing_whitespace, whitespace_characters, whitespace_scope, @@ -174,6 +177,7 @@ pub fn load_config(path: &Path) -> Result { config.popup = popup; config.labels = labels; config.scroll_distance = scroll_distance; + config.scroll_half_page_lines = scroll_half_page_lines; config.highlight_trailing_whitespace = highlight_trailing_whitespace; config.whitespace_characters = whitespace_characters; config.whitespace_scope = whitespace_scope; @@ -512,6 +516,7 @@ struct ConfigFrame { labels: BTreeMap, #[serde(deserialize_with = "scroll_distance")] scroll_distance: Option, + scroll_half_page_lines: Option, highlight_trailing_whitespace: bool, #[serde(deserialize_with = "whitespace_characters")] whitespace_characters: BTreeMap, @@ -1929,6 +1934,50 @@ end } } + #[test] + fn config_scroll_half_page_lines_controls_counted_scrolling_and_rejects_invalid_values() { + let fixture = Fixture::new(); + for (value, expected) in [ + ("nil", 30), + ("1", 3), + ("7", 21), + ("18446744073709551615", isize::MAX), + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.scroll_half_page_lines = {value} }}"), + ); + let mut session = + crate::session::EditorSession::open(None, vec![], Some(&path)).unwrap(); + session.handle(crate::editor::Input::Char('3')); + session.handle(crate::editor::Input::Ctrl('d')); + assert_eq!( + session.editor().take_viewport_scroll(20), + expected, + "{value}" + ); + } + for value in [ + "0", + "-1", + "1.5", + "\"3\"", + "true", + "false", + "18446744073709551616", + ] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.scroll_half_page_lines = {value} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!( + error.contains("scroll_half_page_lines") && error.contains("must be"), + "{value}: {error}" + ); + } + } + #[test] fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() { let fixture = Fixture::new(); diff --git a/src/ruby_config.rb b/src/ruby_config.rb index 7ef3116..3b8a317 100644 --- a/src/ruby_config.rb +++ b/src/ruby_config.rb @@ -145,6 +145,7 @@ module Vamp @popup = PopupConfig.new @labels = {} @scroll_distance = 0 + @scroll_half_page_lines = nil @highlight_trailing_whitespace = false @whitespace_characters = {} @whitespace_scope = "all" @@ -229,6 +230,12 @@ module Vamp @scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0) end + # @param lines [Integer, nil] positive screen rows, or nil for half the viewport + # @return [Integer, nil] + def scroll_half_page_lines=(lines) + @scroll_half_page_lines = lines.nil? ? nil : diff_line_count!(lines, "scroll_half_page_lines", 1) + end + # @param enabled [Boolean] # @return [Boolean] def highlight_trailing_whitespace=(enabled) @@ -343,6 +350,7 @@ module Vamp "git_blame" => @git_blame, "git_gutter" => @git_gutter, "scroll_distance" => @scroll_distance, + "scroll_half_page_lines" => @scroll_half_page_lines, "highlight_trailing_whitespace" => @highlight_trailing_whitespace, "whitespace_characters" => @whitespace_characters, "whitespace_scope" => @whitespace_scope, diff --git a/src/session.rs b/src/session.rs index b0f4c3a..ecdd965 100644 --- a/src/session.rs +++ b/src/session.rs @@ -512,6 +512,7 @@ impl EditorSession { editor.set_git_blame(config.git_blame); editor.set_gutter_style(config.git_gutter); editor.set_scroll_distance(config.scroll_distance); + editor.set_scroll_half_page_lines(config.scroll_half_page_lines); editor.set_highlight_trailing_whitespace(config.highlight_trailing_whitespace); editor.set_whitespace_characters(config.whitespace_characters); editor.set_whitespace_scope(config.whitespace_scope); diff --git a/src/tui.rs b/src/tui.rs index 325309d..841f2b7 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -12617,6 +12617,19 @@ end editor.handle(Input::Ctrl('u')); render(&mut terminal, &mut views, &mut editor); assert_eq!(offset(&views), initial); + editor.set_scroll_half_page_lines(std::num::NonZeroUsize::new(3)); + editor.handle(Input::Char('2')); + editor.handle(Input::Ctrl('d')); + render(&mut terminal, &mut views, &mut editor); + if !wrapped { + assert_eq!(offset(&views), (initial.0 + 6, 0)); + } + assert_eq!(editor.carets(), carets); + assert_eq!(editor.selections(), selections); + editor.handle(Input::Char('2')); + editor.handle(Input::Ctrl('u')); + render(&mut terminal, &mut views, &mut editor); + assert_eq!(offset(&views), initial); if !visual { editor.handle(Input::Char('3')); editor.handle(Input::Alt('j'));