feat: configure cursor distance from viewport edges

This commit is contained in:
2026-10-13 22:10:47 +02:00
parent 00628e0efd
commit b58fb085a3
11 changed files with 592 additions and 82 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
- [ ] make all buffers (including pickers) scrolls the window when the cursor reaches a certain threshold. setting could something like "distance from frame edges", and the value could be an integer or none:
- "none" would mean "always keep the cursor in the center of the frame" (i.e. scroll the viewport whenever the cursor moves to a different line)
- an integer value would be the distance from the edge of the viewport that the cursor must hit before the viewport begins to scroll. a value of 0 means the viewport scrolls only right before the cursor would otherwise have left the viewport; a value greater than half the width of the viewport behaves similarly to "none" (at least until the viewport is resized to support that range in a meaningful way).
- [ ] add option to highlight trailing whitespace (off by default)
- [ ] add option to put characters at certain whitespace characters (off by default)
- whitespace kind -> character map should be configurable.
- [ ] github/gitlab/gitea integration (probably requries authentication with github)
- github pr picker (floating window)
- three tabs: open, closed, all
+2
View File
@@ -78,6 +78,8 @@ Comparison views reserve no separate metadata header or native-buffer help foote
Each comparison owns its split/unified mode and split axis; startup defaults apply only when opening a comparison. Source alignment and file-side availability stay independent of presentation. Unified display rows carry a side projection: equal context maps both sources, changed runs emit Before rows followed by After rows, and folds retain aligned coordinates. Native navigation/editing continues to bind source buffers rather than a synthetic unified document. The TUI reuses side-specific syntax/wrap/overlay pipelines in one unified region or the existing axis-based split geometry, exposing only the active logical side for unified hit-testing and directional focus.
`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.
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.
+5
View File
@@ -15,6 +15,7 @@ Vamp.configure do |config|
config.clipboard_mode = "system"
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.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "tree"
@@ -47,6 +48,10 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.file_explorer_side` defaults to `"right"`; other choices are `"left"`, `"top"`, and `"bottom"`. Independent `file_explorer_view` / `file_picker_view` preferences both default to `"tree"` and accept `"list"` as an alternative. Invalid preference strings report notices and retain defaults while valid bindings still install. Non-string/empty values disable the whole configuration like other Ruby type errors.
`config.clipboard_mode` accepts `"system"` (default) or `"separate"`. An invalid policy string reports a notice and retains System while valid bindings still install; non-string/empty values reject the whole Ruby configuration.
`config.scroll_distance` defaults to `0`: keep the viewport still until its primary cursor or selected picker entry would leave it. A nonnegative integer reserves that many visible rows above/below the cursor, and terminal-cell columns left/right in unwrapped text. `nil` keeps it centered; integers at least half the current viewport dimension behave the same way until resizing makes that margin usable. The start of content limits centering, while trailing blank space allows centering near the end. Whole two-row picker entries stay aligned, using the nearest entry to the center when exact margins cannot fit.
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.git_blame` defaults to `true`; set it to `false` to disable current-line inline Git blame and its background worker. Only literal Ruby booleans are accepted; other values reject the configuration. Toggle it for the running session with `:git_blame.toggle`, its palette action, or a custom binding such as `config.bind "leader b", config.core.git_blame.toggle`. There is no default shortcut.
`config.git_gutter` defaults to `"bar"`: a fat `▌` with one blank column between the line number and marker and another before the text. `"symbols"` uses `+` / `-` / `~` with the same spacing. Added, removed, and modified lines use green, red, and yellow respectively. `"line_numbers"` colors the existing numbers without adding a marker column; the current line retains its cyan/bold caret highlight. `"none"` hides line-gutter repository indicators, not line numbers or picker file statuses. Other modes retain diagnostic number colors independently of repository markers. Unsupported strings, empty strings, and non-string values reject the configuration.
+5 -1
View File
@@ -202,6 +202,10 @@ Live file buffers automatically check disk metadata every 250 ms, including buff
Paste is backend input: it is ignored in Normal, Command, Search, and Visual modes, and inserts literal text in Insert mode, with CRLF/CR normalization and a 4 MiB limit. Pasted text is one undo action, separate from typing before and after it. Rejected paste reports an error without changing text, cursor, mode, or pending insert history. Internal clipboard actions are separate from `Input::Paste` and use already-validated buffer text.
## Cursor-following scrolling
[Configure `scroll_distance`](configuration.md#preferences) to reserve space between the primary cursor or picker selection and the viewport edges. `0` (default) scrolls only to keep it visible; an integer reserves that many visual rows, and columns for unwrapped document text. `nil`, or a margin too large for the current viewport, centers it as far as the start of content permits. This policy applies to documents, historical/diff panes, file explorers, palettes, completion lists, and revision pickers. Reversing direction inside the margin does not move the viewport; resizing recalculates the usable margin.
## Mouse wheel scrolling
Mouse-wheel up/down scrolls the document view three display rows per event without moving the primary or secondary carets, changing selections, or editing text. The caret may scroll offscreen; its terminal cursor is hidden until visible again. Navigation (including a motion blocked at the document boundary), editing, or buffer navigation restores caret-following. Idle updates preserve a wheel-scrolled view, clamped to the document; resizing preserves unwrapped views and restores caret-following in ordinary wrapped buffers.
@@ -386,7 +390,7 @@ Text panes use normal buffer interaction regardless of writability: motions and
**LSP definition, hover, and diagnostics work normally in focused Before and After text panes**, including historical snapshots. Each snapshot is served with its immutable revision text and its own document URI, not the current disk contents. Completion and completion edits are available only in writable After; they use the shared buffer's normal undo history. Revision selectors and the changed-file explorer are not document-action targets.
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. The next motion does not recenter; failed or superseded definitions preserve the current logical viewport.
Accepted LSP definitions center the selection's caret row once in the destination pane, including wrapped visual rows and revealed diff excerpts. Centering uses the focused pane's actual height in either split orientation or unified view. Subsequent motions resume the configured `scroll_distance` policy; failed or superseded definitions preserve the current logical viewport.
A definition whose source range **overlaps an actual added, removed, or replaced line** stays in the same comparison and selects the correct file and side, loading file details on demand. For a location link, the symbol's full target range determines overlap and its selection range determines the caret: an unchanged declaration with a changed body still belongs in the comparison. Source coordinates belong to that side, including old paths for renames; navigation reveals the exact selection row, even when previously omitted, never the nearest excerpt. A wholly unchanged target opens a **new ordinary tab**, even when it is visible context or omitted text in a changed file. Files outside the comparison's changes also open in a new tab. The original comparison, pane layout, source caret, excerpts, and dirty buffers remain intact. Loaded targets share their existing text and undo history rather than reloading disk; references to snapshot URIs retain the historical text and read-only identity in the new tab, even after the comparison closes. Unreadable files, invalid UTF-8, invalid source ranges, and unsupported remote URIs do not create a tab or disturb the source.
+1
View File
@@ -5,6 +5,7 @@ Vamp.configure do |config|
config.leader = "space"
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.file_explorer_side = "right"
config.file_explorer_view = "tree"
config.file_picker_view = "tree"
+10
View File
@@ -1762,6 +1762,7 @@ pub struct Editor {
bottom_bar: ChromeLine,
popup: PopupConfig,
labels: BTreeMap<String, String>,
scroll_distance: Option<usize>,
battery_charge: Option<u8>,
battery_label: String,
git_blame: bool,
@@ -1863,6 +1864,7 @@ impl Editor {
bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(),
labels: BTreeMap::new(),
scroll_distance: Some(0),
battery_charge: None,
battery_label: String::new(),
git_blame: true,
@@ -2601,6 +2603,14 @@ impl Editor {
.map_or(self.active.view.word_wrap, |diff| diff.word_wrap)
}
pub fn scroll_distance(&self) -> Option<usize> {
self.scroll_distance
}
pub(crate) fn set_scroll_distance(&mut self, distance: Option<usize>) {
self.scroll_distance = distance;
}
pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) {
self.diff_context_options = (context_lines, expand_lines);
if let Some(diff) = &mut self.diff {
+8
View File
@@ -93,6 +93,7 @@ pub struct FilePicker {
tree_for_node: Vec<usize>,
tree_roots: Vec<usize>,
rows: Vec<VisibleRow>,
viewport_revision: u64,
expanded: HashSet<usize>,
filter_collapsed: HashSet<usize>,
saved_selection: [Option<usize>; 2],
@@ -298,6 +299,7 @@ impl FilePicker {
tree: Vec::new(),
tree_roots: Vec::new(),
rows: Vec::new(),
viewport_revision: 0,
expanded: HashSet::new(),
filter_collapsed: HashSet::new(),
saved_selection: [None, None],
@@ -346,6 +348,10 @@ impl FilePicker {
tree
}
pub(crate) fn viewport_revision(&self) -> u64 {
self.viewport_revision
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = FileRow<'_>> + '_ {
self.rows.iter().map(|row| {
let node = &self.nodes[row.node];
@@ -542,6 +548,8 @@ impl FilePicker {
}
fn project(&mut self, selected: Option<usize>, select_match: bool) {
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
self.viewport_revision = NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
let matches = |node: &Node| tokens.iter().all(|token| node.search_label.contains(token));
+81 -3
View File
@@ -44,6 +44,7 @@ pub struct Config {
pub bottom_bar: ChromeLine,
pub popup: PopupConfig,
pub labels: BTreeMap<String, String>,
pub scroll_distance: Option<usize>,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
pub diff_view_mode: crate::diff::ViewMode,
@@ -89,6 +90,7 @@ impl Default for Config {
bottom_bar: ChromeLine::bottom(),
popup: PopupConfig::default(),
labels: BTreeMap::new(),
scroll_distance: Some(0),
diff_context_lines: 10,
diff_expand_lines: 15,
diff_view_mode: crate::diff::ViewMode::Split,
@@ -121,6 +123,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
bottom_bar,
popup,
labels,
scroll_distance,
diff_context_lines,
diff_expand_lines,
diff_view_mode,
@@ -154,6 +157,7 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.bottom_bar = bottom_bar;
config.popup = popup;
config.labels = labels;
config.scroll_distance = scroll_distance;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
config.diff_view_mode = diff_view_mode;
@@ -450,6 +454,17 @@ fn diff_expand_lines<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Resul
diff_line_count(deserializer, "diff_expand_lines", 1)
}
fn scroll_distance<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<usize>, D::Error> {
Option::<usize>::deserialize(deserializer).map_err(|error| {
serde::de::Error::custom(format!(
"scroll_distance must be nil or an Integer between 0 and {}: {error}",
usize::MAX
))
})
}
// Startup-only configuration does not inflate every queued plugin response.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
@@ -474,6 +489,8 @@ struct ConfigFrame {
popup: PopupConfig,
#[serde(deserialize_with = "crate::chrome::labels")]
labels: BTreeMap<String, String>,
#[serde(deserialize_with = "scroll_distance")]
scroll_distance: Option<usize>,
#[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
@@ -1217,9 +1234,9 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
@@ -1295,6 +1312,7 @@ end
"leader": "space", "file_explorer_side": "right", "file_picker_view": "tree",
"file_explorer_view": "tree", "clipboard_mode": "system", "git_blame": true,
"git_gutter": "bar",
"scroll_distance": 0,
"top_bar": {}, "bottom_bar": {}, "popup": {"command": {}, "search": {}},
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1545,6 +1563,7 @@ end
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"scroll_distance": 0,
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
@@ -1585,6 +1604,64 @@ end
}
}
#[test]
fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() {
let fixture = Fixture::new();
let frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"clipboard_mode": "system", "git_blame": true, "git_gutter": "bar",
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"diff_follow_working_copy": true, "scroll_distance": 0
});
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
let value = distance.map_or_else(|| "nil".into(), |value| value.to_string());
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
);
assert_eq!(load_config(&path).unwrap().scroll_distance, distance);
let mut valid_frame = frame.clone();
valid_frame["scroll_distance"] = serde_json::json!(distance);
let response = serde_json::from_value::<Response>(valid_frame).unwrap();
let Response::Config(config) = response else {
panic!("expected a configuration frame");
};
assert_eq!(config.scroll_distance, distance);
}
for value in [
"-1",
"1.5",
"\"3\"",
"true",
"false",
"18446744073709551616",
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.scroll_distance = {value} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(
error.contains("scroll_distance") && error.contains("must be"),
"{value}: {error}"
);
let mut invalid_frame = frame.clone();
invalid_frame["scroll_distance"] = serde_json::from_str(value).unwrap();
let error = serde_json::from_value::<Response>(invalid_frame)
.err()
.unwrap();
assert!(
error.to_string().contains("scroll_distance"),
"{value}: {error}"
);
}
}
#[test]
fn lsp_configuration_is_disabled_unless_explicitly_enabled() {
let fixture = Fixture::new();
@@ -1962,6 +2039,7 @@ end"#,
"file_picker_view": "tree", "file_explorer_view": "tree",
"git_blame": true,
"git_gutter": "bar",
"scroll_distance": 0,
"top_bar": {}, "bottom_bar": {}, "labels": {},
"popup": {"command": {}, "search": {}},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
+8
View File
@@ -141,6 +141,7 @@ module Vamp
@bar = BarConfig.new
@popup = PopupConfig.new
@labels = {}
@scroll_distance = 0
@diff_context_lines = 10
@diff_expand_lines = 15
@diff_view_mode = "split"
@@ -214,6 +215,12 @@ module Vamp
end
end
# @param distance [Integer, nil] nonnegative cursor margin, or nil to center
# @return [Integer, nil]
def scroll_distance=(distance)
@scroll_distance = distance.nil? ? nil : diff_line_count!(distance, "scroll_distance", 0)
end
# @param lines [Integer] nonnegative context lines around each diff change
# @return [Integer]
def diff_context_lines=(lines)
@@ -285,6 +292,7 @@ module Vamp
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"git_blame" => @git_blame,
"git_gutter" => @git_gutter,
"scroll_distance" => @scroll_distance,
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"popup" => { "command" => @popup.command, "search" => @popup.search },
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
+1
View File
@@ -256,6 +256,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_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
if let Err(error) =
+468 -75
View File
@@ -2,6 +2,7 @@ use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet},
fmt::Write as _,
hash::{Hash, Hasher},
io::{self, Stdout},
panic::{self, AssertUnwindSafe, PanicHookInfo},
sync::{Arc, LazyLock, Mutex},
@@ -277,6 +278,27 @@ fn clamp_visual_bottom(
start
}
fn scroll_margin(size: usize, distance: Option<usize>) -> usize {
distance.unwrap_or(size / 2).min(size / 2)
}
fn scroll_offset(top: usize, cursor: usize, size: usize, distance: Option<usize>) -> usize {
if size == 0 {
return top;
}
let margin = scroll_margin(size, distance);
if margin == size / 2 {
return cursor.saturating_sub(margin);
}
if cursor < top.saturating_add(margin) {
cursor.saturating_sub(margin)
} else if cursor >= top.saturating_add(size - margin) {
cursor.saturating_sub(size - margin - 1)
} else {
top
}
}
#[derive(Default)]
struct BufferViewports {
offsets: BTreeMap<usize, (usize, usize)>,
@@ -508,6 +530,7 @@ impl BufferViewports {
if let Some(panel) = dock
&& let Some(picker) = editor.file_picker()
{
self.panels.picker_scroll.distance = editor.scroll_distance();
draw_file_list(
frame,
picker,
@@ -1617,7 +1640,7 @@ fn draw_document(
),
));
if !wrapped {
let limit = if document.anchor.is_some() {
let limit = if document.anchor.is_some() || editor.scroll_distance() != Some(0) {
editor.lines().len() - 1
} else {
editor.lines().len().saturating_sub(height)
@@ -1661,7 +1684,24 @@ fn draw_document(
column,
),
};
if let Some(row) = toggle_row {
let margin = scroll_margin(height, editor.scroll_distance());
let anchor = toggle_row.or_else(|| {
if margin == height / 2 {
return Some(margin);
}
let (first, count) =
visual_page(&mut document.wrap, editor.lines(), start, margin + 1);
let last =
visual_page(&mut document.wrap, editor.lines(), start, height - margin).0;
if caret < first || count <= margin {
Some(margin)
} else if caret > last {
Some(height - margin - 1)
} else {
None
}
});
if let Some(row) = anchor {
start = caret;
for _ in 0..row {
let Some(previous) =
@@ -1671,21 +1711,9 @@ fn draw_document(
};
start = previous;
}
} else if caret < start {
start = caret;
} else if caret > visual_page(&mut document.wrap, editor.lines(), start, height).0 {
start = caret;
for _ in 1..height {
let Some(previous) =
previous_visual(&mut document.wrap, editor.lines(), start)
else {
break;
};
start = previous;
}
}
}
if document.anchor.is_none() {
if document.anchor.is_none() && editor.scroll_distance() == Some(0) {
start = clamp_visual_bottom(&mut document.wrap, editor.lines(), start, height);
}
*top = start.source;
@@ -1706,17 +1734,11 @@ fn draw_document(
}
} else {
if document.manual.is_none() {
if cursor.row < *top {
*top = cursor.row;
} else if cursor.row >= top.saturating_add(height) {
*top = cursor.row + 1 - height;
if toggle_row.is_none() {
*top = scroll_offset(*top, cursor.row, height, editor.scroll_distance());
}
let column = display_column(&editor.lines()[cursor.row], cursor.column);
if column < *left {
*left = column;
} else if column >= left.saturating_add(width) {
*left = column + 1 - width;
}
*left = scroll_offset(*left, column, width, editor.scroll_distance());
}
document.visible.extend(
(*top..top.saturating_add(height).min(editor.lines().len())).map(|row| {
@@ -1923,6 +1945,7 @@ fn draw_document(
if let Some(panel) = dock
&& let Some(picker) = editor.file_picker()
{
panels.picker_scroll.distance = editor.scroll_distance();
draw_file_list(
frame,
picker,
@@ -2059,6 +2082,16 @@ fn draw_overlays(
caret: Option<(Rect, Position)>,
panels: &mut WheelPanels,
) {
panels.picker_scroll.distance = editor.scroll_distance();
if editor.completion().is_none() {
panels.picker_scroll.completion = None;
}
if editor.palette().is_none() {
panels.picker_scroll.palette = None;
}
if editor.diff().is_none_or(|diff| diff.phase == Phase::View) {
panels.picker_scroll.revisions = None;
}
panels.input = None;
panels.notification = None;
let covered = editor.completion().is_some()
@@ -2089,7 +2122,7 @@ fn draw_overlays(
if let Some(completion) = editor.completion()
&& let Some((_, caret)) = caret
{
panels.completion = draw_completion(frame, completion, area, caret);
panels.completion = draw_completion(frame, completion, area, caret, panels);
}
if let Some(picker) = editor.file_picker()
&& picker.presentation == FilePresentation::Floating
@@ -2116,6 +2149,7 @@ fn draw_overlays(
area,
editor.mode(),
editor.selection_navigation_hint(),
panels,
);
}
if let Some(hover) = editor.hover() {
@@ -2415,6 +2449,12 @@ fn draw_diff(
panels: &mut WheelPanels,
editor: Option<&Editor>,
) {
if let Some(editor) = editor {
panels.picker_scroll.distance = editor.scroll_distance();
}
if diff.phase == Phase::View {
panels.picker_scroll.revisions = None;
}
let comparison = diff.comparison.as_ref();
let selected = comparison.and_then(|comparison| comparison.files.get(diff.file));
if diff.phase != Phase::View || !diff.selected_file_loaded() {
@@ -2551,7 +2591,26 @@ fn draw_diff(
1
};
let count = usize::from(results.height) / row_height;
let first = diff.selected.saturating_sub(count.saturating_sub(1)).max(1);
let mut key = std::collections::hash_map::DefaultHasher::new();
(
diff.phase as u8,
diff.category,
&diff.query,
diff.comparison_mode as u8,
diff.catalog
.as_ref()
.map(|catalog| (catalog.entries.as_ptr(), catalog.entries.len())),
)
.hash(&mut key);
let first = PickerViewport::follow(
&mut panels.picker_scroll.revisions,
key.finish(),
diff.selected.saturating_sub(1),
usize::from(results.height),
row_height,
panels.picker_scroll.distance,
)
.saturating_add(1);
let mut lines = Vec::with_capacity(count * row_height);
for (index, revision) in entries
.enumerate()
@@ -2744,7 +2803,14 @@ fn draw_diff(
let viewport = panels
.diff_wrap
.get_or_insert_with(DiffWrapViewport::default);
viewport.update(diff, file, [before_area, after_area], caret, toggle_row);
viewport.update(
diff,
file,
[before_area, after_area],
caret,
toggle_row,
editor.map_or(Some(0), Editor::scroll_distance),
);
top = viewport.top.display;
} else {
panels.diff_wrap = None;
@@ -2763,10 +2829,8 @@ fn draw_diff(
if let Some(anchor) = toggle_row {
top = row.saturating_sub(anchor.min(height.saturating_sub(1)));
}
if row < top {
top = row;
} else if height > 0 && row >= top.saturating_add(height) {
top = row + 1 - height;
if toggle_row.is_none() {
top = scroll_offset(top, row, height, editor.scroll_distance());
}
}
let source_len = selected.map_or(0, |file| match focus {
@@ -2779,11 +2843,7 @@ fn draw_diff(
|file| diff_pane_text_width(diff, file, pane, focus),
);
let column = display_column(&editor.lines()[editor.cursor().row], editor.cursor().column);
if column < *left {
*left = column;
} else if width > 0 && column >= left.saturating_add(width) {
*left = column + 1 - width;
}
*left = scroll_offset(*left, column, width, editor.scroll_distance());
}
if let Some(id) = diff.comparison_id
&& let Some(file) = selected.filter(|file| file.loaded)
@@ -3479,6 +3539,7 @@ impl DiffWrapViewport {
areas: [Rect; 2],
caret: Option<(Focus, Cursor)>,
anchor_row: Option<usize>,
distance: Option<usize>,
) {
let Some(id) = diff.comparison_id else { return };
let key = Some((id, diff.file, diff.live_revision));
@@ -3522,23 +3583,29 @@ impl DiffWrapViewport {
if let Some((focus, cursor)) = caret
&& let Some(position) = self.caret_position(diff, file, focus, cursor)
{
if let Some(row) = anchor_row {
self.top = position;
for _ in 0..row.min(height.saturating_sub(1)) {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, paint_height);
} else if position < self.top {
self.top = position;
self.fill(diff, file, paint_height);
} else if !self
let margin = scroll_margin(height, distance);
let row = self
.rows
.iter()
.take(height)
.any(|row| row.position == position)
{
.position(|row| row.position == position);
let anchor = anchor_row.or_else(|| {
if height == 0 {
None
} else if margin == height / 2
|| position < self.top
|| row.is_some_and(|row| row < margin)
{
Some(margin)
} else if row.is_none_or(|row| row >= height - margin) {
Some(height - margin - 1)
} else {
None
}
});
if let Some(row) = anchor {
self.top = position;
for _ in 1..height {
for _ in 0..row.min(height.saturating_sub(1)) {
self.top = self.step(diff, file, self.top, false);
}
self.fill(diff, file, paint_height);
@@ -3716,6 +3783,7 @@ struct WheelPanels {
hover_syntax: SyntaxCache,
files: Option<(Rect, usize, usize)>,
manual_files: Option<FileWheelOffset>,
picker_scroll: PickerScroll,
diff: Option<DiffGeometry>,
diff_colors: DiffColors,
diff_syntax: Option<DiffSyntaxCache>,
@@ -3723,27 +3791,98 @@ struct WheelPanels {
diff_wrap: Option<DiffWrapViewport>,
}
struct PickerScroll {
distance: Option<usize>,
files: Option<PickerViewport>,
completion: Option<PickerViewport>,
palette: Option<PickerViewport>,
revisions: Option<PickerViewport>,
}
impl Default for PickerScroll {
fn default() -> Self {
Self {
distance: Some(0),
files: None,
completion: None,
palette: None,
revisions: None,
}
}
}
#[derive(Default)]
struct PickerViewport {
key: u64,
top: usize,
}
impl PickerViewport {
fn follow(
viewport: &mut Option<Self>,
key: u64,
selected: usize,
height: usize,
row_height: usize,
distance: Option<usize>,
) -> usize {
let viewport = viewport.get_or_insert_with(Self::default);
if viewport.key != key {
viewport.key = key;
viewport.top = 0;
}
if height < row_height {
return viewport.top;
}
let margin = scroll_margin(height, distance);
let top = scroll_offset(
viewport.top.saturating_mul(row_height),
selected.saturating_mul(row_height),
height,
distance,
);
let first = selected.saturating_sub(margin.div_ceil(row_height));
let last = selected
.saturating_add(1)
.saturating_sub((height - margin) / row_height);
viewport.top = if margin == height / 2 || last > first {
selected.saturating_sub((height / 2) / row_height)
} else {
top.div_ceil(row_height).clamp(last, first)
};
viewport.top
}
}
fn file_picker_key(picker: &FilePicker) -> u64 {
let mut key = std::collections::hash_map::DefaultHasher::new();
(
&picker.root,
&picker.query,
picker.presentation as u8,
picker.view as u8,
picker.viewport_revision(),
)
.hash(&mut key);
key.finish()
}
struct FileWheelOffset {
top: usize,
root: std::path::PathBuf,
query: String,
state: (usize, usize, FilePresentation, FileView, bool, bool),
state: (usize, u64),
}
impl FileWheelOffset {
fn state(picker: &FilePicker) -> (usize, usize, FilePresentation, FileView, bool, bool) {
(
picker.selected,
picker.entries().len(),
picker.presentation,
picker.view,
picker.searching,
picker.focused,
)
fn state(picker: &FilePicker) -> (usize, u64) {
(picker.selected, file_picker_key(picker))
}
fn matches(&self, picker: &FilePicker) -> bool {
self.state == Self::state(picker) && self.root == picker.root && self.query == picker.query
fn matches(&self, picker: &FilePicker, key: u64) -> bool {
self.state == (picker.selected, key)
&& self.root == picker.root
&& self.query == picker.query
}
}
@@ -3751,6 +3890,7 @@ impl WheelPanels {
fn begin_frame(&mut self) {
if self.files.is_none() {
self.manual_files = None;
self.picker_scroll.files = None;
}
self.hover = None;
self.caret = None;
@@ -3761,28 +3901,35 @@ impl WheelPanels {
self.previous_diff = self.diff.take();
}
// Keyboard/paste may change the catalog without changing its length.
fn follow_files(&mut self) {
self.manual_files = None;
}
fn file_top(&mut self, picker: &FilePicker, panel: Rect, height: usize) -> usize {
let key = FileWheelOffset::state(picker).1;
if self
.manual_files
.as_ref()
.is_some_and(|offset| !offset.matches(picker))
.is_some_and(|offset| !offset.matches(picker, key))
{
self.manual_files = None;
}
let count = picker.entries().len();
let following = picker
.selected
.min(count.saturating_sub(1))
.saturating_sub(height.saturating_sub(1));
let following = PickerViewport::follow(
&mut self.picker_scroll.files,
key,
picker.selected.min(count.saturating_sub(1)),
height,
1,
self.picker_scroll.distance,
);
let top = self.manual_files.as_mut().map_or(following, |offset| {
offset.top = offset.top.min(count.saturating_sub(height.max(1)));
offset.top
});
if let Some(viewport) = &mut self.picker_scroll.files {
viewport.top = top;
}
self.files = Some((panel, height, top));
top
}
@@ -3866,10 +4013,11 @@ impl WheelPanels {
&& panel.contains(position)
{
if height > 0 {
let key = file_picker_key(picker);
if self
.manual_files
.as_ref()
.is_some_and(|offset| !offset.matches(picker))
.is_some_and(|offset| !offset.matches(picker, key))
{
self.manual_files = None;
}
@@ -3877,7 +4025,7 @@ impl WheelPanels {
top: drawn_top,
root: picker.root.clone(),
query: picker.query.clone(),
state: FileWheelOffset::state(picker),
state: (picker.selected, key),
});
offset.top = if down {
offset.top.saturating_add(3)
@@ -3964,6 +4112,7 @@ fn draw_completion(
completion: &Completion,
area: Rect,
caret: Position,
panels: &mut WheelPanels,
) -> Option<Rect> {
if area.width == 0 || area.height == 0 || !area.contains(caret) {
return None;
@@ -3975,7 +4124,16 @@ fn draw_completion(
let above = caret.y - area.y;
let downward = below >= desired_height || below >= above;
let height = desired_height.min(if downward { below } else { above }.max(1));
let first = selected.saturating_sub(usize::from(height) - 1);
let mut key = std::collections::hash_map::DefaultHasher::new();
(completion.items.as_ptr(), completion.items.len()).hash(&mut key);
let first = PickerViewport::follow(
&mut panels.picker_scroll.completion,
key.finish(),
selected,
usize::from(height),
1,
panels.picker_scroll.distance,
);
let lines: Vec<Line> = (first..count)
.take(usize::from(height))
.map(|index| {
@@ -4406,6 +4564,7 @@ fn draw_palette(
area: Rect,
mode: Mode,
navigation_hint: &str,
panels: &mut WheelPanels,
) {
if area.width == 0 || area.height == 0 {
return;
@@ -4473,6 +4632,7 @@ fn draw_palette(
);
frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y));
if palette.entries.is_empty() {
panels.picker_scroll.palette = None;
frame.render_widget(
Paragraph::new(if palette.buffers {
"No matching buffers"
@@ -4488,7 +4648,17 @@ fn draw_palette(
usize::from(results_area.height >= 2) + 1
};
let count = usize::from(results_area.height) / row_height;
let first = palette.selected.saturating_sub(count - 1);
let mut key = std::collections::hash_map::DefaultHasher::new();
(&palette.query, palette.buffers).hash(&mut key);
(palette.entries.as_ptr(), palette.entries.len()).hash(&mut key);
let first = PickerViewport::follow(
&mut panels.picker_scroll.palette,
key.finish(),
palette.selected.min(palette.entries.len() - 1),
usize::from(results_area.height),
row_height,
panels.picker_scroll.distance,
);
let id_width = palette
.entries
.last()
@@ -4689,6 +4859,109 @@ mod tests {
}
}
#[test]
fn scroll_distance_follows_document_and_diff_visual_rows_and_columns() {
let directory = Directory::new();
let text = (0..80)
.map(|row| format!("row-{row:02} {}", "x".repeat(160)))
.collect::<Vec<_>>()
.join("\n");
for diff in [false, true] {
for wrapped in [false, true] {
for distance in [Some(0), Some(2), None, Some(usize::MAX)] {
let mut editor = if diff {
let mut editor = readonly_diff_editor(syntax_diff_file(
"history.txt",
&text,
&text.replace('x', "y"),
));
editor.handle(Input::Char('\t'));
editor
} else {
Editor::open(Some(directory.file("document.txt", &text)), vec![]).unwrap()
};
editor.set_scroll_distance(distance);
if wrapped {
editor.execute("word_wrap.toggle");
}
let mut views = BufferViewports::default();
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(80, 16)).unwrap();
let render = |terminal: &mut Terminal<ratatui::backend::TestBackend>,
views: &mut BufferViewports,
editor: &mut Editor| {
if diff {
render_native_diff(terminal, views, editor)
} else {
terminal.draw(|frame| views.draw(frame, editor)).unwrap();
terminal.get_cursor_position().unwrap()
}
};
render(&mut terminal, &mut views, &mut editor);
for _ in 0..30 {
editor.handle(Input::Down);
render(&mut terminal, &mut views, &mut editor);
}
let viewport = if diff {
diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0)
} else {
views.document.geometry.unwrap().1
};
let height = usize::from(viewport.height);
let expected = if distance.is_none_or(|distance| distance >= height / 2) {
height / 2
} else {
height - distance.unwrap() - 1
};
let caret = terminal.get_cursor_position().unwrap();
assert_eq!(
usize::from(caret.y - viewport.y),
expected,
"diff={diff}, wrap={wrapped}, distance={distance:?}"
);
if !wrapped && distance == Some(2) {
editor.handle(Input::Up);
let reversed = render(&mut terminal, &mut views, &mut editor);
assert_eq!(reversed.y + 1, caret.y);
}
if !wrapped {
for _ in 0..120 {
editor.handle(Input::Right);
render(&mut terminal, &mut views, &mut editor);
}
let left = if diff {
editor.diff().unwrap().left_before
} else {
views.offsets[&editor.buffer_id()].1
};
let width = if diff {
usize::from(views.panels.diff.as_ref().unwrap().text_widths[0])
} else {
views.document.text_width
};
let expected = if distance.is_none_or(|distance| distance >= width / 2) {
width / 2
} else {
width - distance.unwrap() - 1
};
assert_eq!(editor.cursor().column - left, expected);
}
if distance == Some(usize::MAX) {
terminal.backend_mut().resize(60, 12);
terminal.resize(Rect::new(0, 0, 60, 12)).unwrap();
let caret = render(&mut terminal, &mut views, &mut editor);
let viewport = if diff {
diff_inner(views.panels.diff.as_ref().unwrap().regions[1].0)
} else {
views.document.geometry.unwrap().1
};
assert_eq!(caret.y - viewport.y, viewport.height / 2);
}
}
}
}
}
fn screen_text(terminal: &Terminal<ratatui::backend::TestBackend>) -> String {
let buffer = terminal.backend().buffer();
let area = buffer.area;
@@ -10216,7 +10489,13 @@ end
.collect();
frame.render_widget(Paragraph::new(rows), frame.area());
frame.set_cursor_position(caret);
panel = draw_completion(frame, &completion, workspace, caret);
panel = draw_completion(
frame,
&completion,
workspace,
caret,
&mut WheelPanels::default(),
);
})
.unwrap();
let mut panel = panel.unwrap();
@@ -10247,7 +10526,14 @@ end
terminal
.draw(|frame| {
frame.set_cursor_position(caret);
panel = draw_completion(frame, &completion, workspace, caret).unwrap();
panel = draw_completion(
frame,
&completion,
workspace,
caret,
&mut WheelPanels::default(),
)
.unwrap();
})
.unwrap();
assert_eq!(
@@ -11634,7 +11920,6 @@ end
panels.manual_files.as_ref().unwrap().top,
session.editor().file_picker().unwrap().entries().len() - height
);
panels.follow_files();
terminal
.draw(|frame| {
draw_file_list(
@@ -11647,7 +11932,8 @@ end
)
})
.unwrap();
assert_eq!(panels.files.unwrap().2, 0);
assert_eq!(panels.files.unwrap().2, entry_count - height);
panels.follow_files();
session.handle(Input::Ctrl('p'));
assert!(session.editor().palette().is_some());
assert!(panels.scroll(&mut session, mouse));
@@ -11711,6 +11997,110 @@ end
assert!(screen_text(&terminal).contains("> file-10"));
}
#[test]
fn picker_viewport_preserves_direction_and_uses_visual_row_margins() {
for row_height in [1, 2] {
let mut viewport = None;
let height = 4 * row_height;
for selected in 0..8 {
PickerViewport::follow(&mut viewport, 1, selected, height, row_height, Some(0));
}
for selected in (4..8).rev() {
assert_eq!(
PickerViewport::follow(&mut viewport, 1, selected, height, row_height, Some(0)),
4,
);
}
assert_eq!(
PickerViewport::follow(&mut viewport, 1, 3, height, row_height, Some(0)),
3,
);
assert_eq!(
PickerViewport::follow(&mut viewport, 2, 3, height, row_height, Some(0)),
0,
);
for distance in [None, Some(usize::MAX)] {
assert_eq!(
PickerViewport::follow(&mut viewport, 2, 7, height, row_height, distance),
5,
);
assert_eq!(
PickerViewport::follow(&mut viewport, 2, 0, height, row_height, distance),
0,
);
}
}
let mut viewport = None;
assert_eq!(
PickerViewport::follow(&mut viewport, 1, 7, 8, 2, Some(1)),
5
);
assert_eq!(
PickerViewport::follow(&mut viewport, 1, 6, 8, 2, Some(1)),
5
);
assert_eq!(
PickerViewport::follow(&mut viewport, 1, 7, 8, 2, Some(3)),
5
);
assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 7, 2, None), 6);
assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 3, 1, None), 6);
assert_eq!(PickerViewport::follow(&mut viewport, 1, 7, 0, 1, None), 6);
}
#[test]
fn file_viewport_keeps_manual_wheel_until_selection_or_catalog_changes() {
let directory = Directory::new();
for index in 0..12 {
directory.file(&format!("file-{index:02}"), "");
}
let mut picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::List,
)
.unwrap();
let mut panels = WheelPanels::default();
let area = Rect::new(0, 0, 40, 8);
picker.selected = 7;
assert_eq!(panels.file_top(&picker, area, 5), 3);
picker.selected = 6;
assert_eq!(panels.file_top(&picker, area, 5), 3);
panels.picker_scroll.distance = None;
panels.manual_files = Some(FileWheelOffset {
top: 0,
root: picker.root.clone(),
query: picker.query.clone(),
state: FileWheelOffset::state(&picker),
});
picker.focused = !picker.focused;
assert_eq!(panels.file_top(&picker, area, 3), 0);
assert!(panels.manual_files.is_some());
picker.selected = 7;
assert_eq!(panels.file_top(&picker, area, 5), 5);
assert!(panels.manual_files.is_none());
panels.manual_files = Some(FileWheelOffset {
top: 0,
root: picker.root.clone(),
query: picker.query.clone(),
state: FileWheelOffset::state(&picker),
});
std::fs::rename(directory.0.join("file-00"), directory.0.join("new-file")).unwrap();
picker = FilePicker::scan(
directory.0.clone(),
FilePresentation::Docked,
FileView::List,
)
.unwrap();
picker.selected = 7;
assert_eq!(picker.entries().len(), 12);
assert_eq!(panels.file_top(&picker, area, 5), 5);
assert!(panels.manual_files.is_none());
panels.begin_frame();
panels.begin_frame();
assert!(panels.picker_scroll.files.is_none());
}
#[test]
fn file_list_window_follows_control_j_k_selection() {
let mut editor = Editor::open(None, vec![]).unwrap();
@@ -14970,6 +15360,7 @@ end
area,
editor.mode(),
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
)
})
.unwrap();
@@ -15030,6 +15421,7 @@ end
area,
Mode::Visual,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
)
})
.unwrap();
@@ -15073,6 +15465,7 @@ end
area,
Mode::Normal,
editor.selection_navigation_hint(),
&mut WheelPanels::default(),
)
})
.unwrap();