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
+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();