diff --git a/benches/criterion.rs b/benches/criterion.rs index 04d31a4..f9bddee 100644 --- a/benches/criterion.rs +++ b/benches/criterion.rs @@ -121,7 +121,7 @@ fn draw_benchmark(c: &mut Criterion) { c.bench_function("draw on terminal", |b| { b.iter(|| { - terminal.draw(|f| ui::draw(f, &app, &lua)).unwrap(); + terminal.draw(|f| ui::draw(f, &mut app, &lua)).unwrap(); }) }); diff --git a/docs/en/src/general-config.md b/docs/en/src/general-config.md index 75fa861..a986348 100644 --- a/docs/en/src/general-config.md +++ b/docs/en/src/general-config.md @@ -42,6 +42,12 @@ Set it to `true` if you want to hide all remaps in the help menu. Type: boolean +#### xplr.config.general.vimlike_scrolling + +Set it to `true` if you want vim-like scrolling. + +Type: boolean + #### xplr.config.general.enforce_bounded_index_navigation Set it to `true` if you want the cursor to stay in the same position when diff --git a/src/app.rs b/src/app.rs index 49f3b33..19e3268 100644 --- a/src/app.rs +++ b/src/app.rs @@ -752,7 +752,10 @@ impl App { self.explorer_config.clone(), self.pwd.clone().into(), focus.as_ref().map(PathBuf::from), - self.directory_buffer.as_ref().map(|d| d.focus).unwrap_or(0), + self.directory_buffer + .as_ref() + .map(|d| d.scroll_state.get_focus()) + .unwrap_or(0), ) { Ok(dir) => self.set_directory(dir), Err(e) => { @@ -791,7 +794,7 @@ impl App { } } - dir.focus = 0; + dir.scroll_state.set_focus(0); if save_history { if let Some(n) = self.focused_node() { @@ -809,7 +812,7 @@ impl App { history = history.push(n.absolute_path.clone()); } - dir.focus = dir.total.saturating_sub(1); + dir.scroll_state.set_focus(dir.total.saturating_sub(1)); if let Some(n) = dir.focused_node() { self.history = history.push(n.absolute_path.clone()); @@ -822,14 +825,13 @@ impl App { let bounded = self.config.general.enforce_bounded_index_navigation; if let Some(dir) = self.directory_buffer_mut() { - dir.focus = if dir.focus == 0 { - if bounded { - dir.focus - } else { - dir.total.saturating_sub(1) + if dir.scroll_state.get_focus() == 0 { + if !bounded { + dir.scroll_state.set_focus(dir.total.saturating_sub(1)); } } else { - dir.focus.saturating_sub(1) + dir.scroll_state + .set_focus(dir.scroll_state.get_focus().saturating_sub(1)); }; }; Ok(self) @@ -882,7 +884,8 @@ impl App { history = history.push(n.absolute_path.clone()); } - dir.focus = dir.focus.saturating_sub(index); + dir.scroll_state + .set_focus(dir.scroll_state.get_focus().saturating_sub(index)); if let Some(n) = self.focused_node() { self.history = history.push(n.absolute_path.clone()); } @@ -907,14 +910,12 @@ impl App { let bounded = self.config.general.enforce_bounded_index_navigation; if let Some(dir) = self.directory_buffer_mut() { - dir.focus = if (dir.focus + 1) == dir.total { - if bounded { - dir.focus - } else { - 0 + if (dir.scroll_state.get_focus() + 1) == dir.total { + if !bounded { + dir.scroll_state.set_focus(0); } } else { - dir.focus + 1 + dir.scroll_state.set_focus(dir.scroll_state.get_focus() + 1); } }; Ok(self) @@ -967,10 +968,12 @@ impl App { history = history.push(n.absolute_path.clone()); } - dir.focus = dir - .focus - .saturating_add(index) - .min(dir.total.saturating_sub(1)); + dir.scroll_state.set_focus( + dir.scroll_state + .get_focus() + .saturating_add(index) + .min(dir.total.saturating_sub(1)), + ); if let Some(n) = self.focused_node() { self.history = history.push(n.absolute_path.clone()); @@ -1238,7 +1241,8 @@ impl App { fn focus_by_index(mut self, index: usize) -> Result { let history = self.history.clone(); if let Some(dir) = self.directory_buffer_mut() { - dir.focus = index.min(dir.total.saturating_sub(1)); + dir.scroll_state + .set_focus(index.min(dir.total.saturating_sub(1))); if let Some(n) = self.focused_node() { self.history = history.push(n.absolute_path.clone()); } @@ -1275,7 +1279,7 @@ impl App { history = history.push(n.absolute_path.clone()); } } - dir_buf.focus = focus; + dir_buf.scroll_state.set_focus(focus); if save_history { if let Some(n) = dir_buf.focused_node() { self.history = history.push(n.absolute_path.clone()); diff --git a/src/config.rs b/src/config.rs index db08f86..05f9998 100644 --- a/src/config.rs +++ b/src/config.rs @@ -353,6 +353,9 @@ pub struct GeneralConfig { #[serde(default)] pub global_key_bindings: KeyBindings, + + #[serde(default)] + pub vimlike_scrolling: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/directory_buffer.rs b/src/directory_buffer.rs index c6e755d..aa60eeb 100644 --- a/src/directory_buffer.rs +++ b/src/directory_buffer.rs @@ -2,31 +2,123 @@ use crate::node::Node; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct ScrollState { + current_focus: usize, + pub last_focus: Option, + pub skipped_rows: usize, + /* The number of visible next lines when scrolling towards either ends of the view port */ + pub initial_preview_cushion: usize, +} + +impl ScrollState { + pub fn set_focus(&mut self, current_focus: usize) { + self.last_focus = Some(self.current_focus); + self.current_focus = current_focus; + } + + pub fn get_focus(&self) -> usize { + self.current_focus + } + + pub fn calc_skipped_rows( + &self, + height: usize, + total: usize, + vimlike_scrolling: bool, + ) -> usize { + let preview_cushion = if height >= self.initial_preview_cushion * 3 { + self.initial_preview_cushion + } else if height >= 9 { + 3 + } else if height >= 3 { + 1 + } else { + 0 + }; + + let current_focus = self.current_focus; + let last_focus = self.last_focus; + let first_visible_row = self.skipped_rows; + + // Calculate the cushion rows at the start and end of the view port + let start_cushion_row = first_visible_row + preview_cushion; + let end_cushion_row = (first_visible_row + height) + .saturating_sub(preview_cushion + 1) + .min(total.saturating_sub(preview_cushion + 1)); + + if !vimlike_scrolling { + height * (self.current_focus / height.max(1)) + } else if last_focus.is_none() { + // Just entered the directory + 0 + } else if current_focus == 0 { + // When focus goes to first node + 0 + } else if current_focus == total.saturating_sub(1) { + // When focus goes to last node + total.saturating_sub(height) + } else if (start_cushion_row..=end_cushion_row).contains(¤t_focus) { + // If within cushioned area; do nothing + first_visible_row + } else if current_focus > last_focus.unwrap() { + // When scrolling down the cushioned area + if current_focus > total.saturating_sub(preview_cushion + 1) { + // When focusing the last nodes; always view the full last page + total.saturating_sub(height) + } else { + // When scrolling down the cushioned area without reaching the last nodes + current_focus.saturating_sub(height.saturating_sub(preview_cushion + 1)) + } + } else if current_focus < last_focus.unwrap() { + // When scrolling up the cushioned area + if current_focus < preview_cushion { + // When focusing the first nodes; always view the full first page + 0 + } else if current_focus > end_cushion_row { + // When scrolling up from the last rows; do nothing + first_visible_row + } else { + // When scrolling up the cushioned area without reaching the first nodes + current_focus.saturating_sub(preview_cushion) + } + } else { + // If nothing matches; do nothing + first_visible_row + } + } +} + #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] pub struct DirectoryBuffer { pub parent: String, pub nodes: Vec, pub total: usize, - pub focus: usize, + pub scroll_state: ScrollState, #[serde(skip, default = "now")] pub explored_at: OffsetDateTime, } impl DirectoryBuffer { - pub fn new(parent: String, nodes: Vec, focus: usize) -> Self { + pub fn new(parent: String, nodes: Vec, current_focus: usize) -> Self { let total = nodes.len(); Self { parent, nodes, total, - focus, + scroll_state: ScrollState { + current_focus, + last_focus: None, + skipped_rows: 0, + initial_preview_cushion: 5, + }, explored_at: now(), } } pub fn focused_node(&self) -> Option<&Node> { - self.nodes.get(self.focus) + self.nodes.get(self.scroll_state.current_focus) } } @@ -35,3 +127,112 @@ fn now() -> OffsetDateTime { .ok() .unwrap_or_else(OffsetDateTime::now_utc) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calc_skipped_rows_non_vimlike_scrolling() { + let state = ScrollState { + current_focus: 10, + last_focus: Some(8), + skipped_rows: 0, + initial_preview_cushion: 5, + }; + + let height = 5; + let total = 20; + let vimlike_scrolling = false; + + let result = state.calc_skipped_rows(height, total, vimlike_scrolling); + assert_eq!(result, height * (state.current_focus / height.max(1))); + } + + #[test] + fn test_calc_skipped_rows_entered_directory() { + let state = ScrollState { + current_focus: 10, + last_focus: None, + skipped_rows: 0, + initial_preview_cushion: 5, + }; + + let height = 5; + let total = 20; + let vimlike_scrolling = true; + + let result = state.calc_skipped_rows(height, total, vimlike_scrolling); + assert_eq!(result, 0); + } + + #[test] + fn test_calc_skipped_rows_top_of_directory() { + let state = ScrollState { + current_focus: 0, + last_focus: Some(8), + skipped_rows: 5, + initial_preview_cushion: 5, + }; + + let height = 5; + let total = 20; + let vimlike_scrolling = true; + + let result = state.calc_skipped_rows(height, total, vimlike_scrolling); + assert_eq!(result, 0); + } + + #[test] + fn test_calc_skipped_rows_bottom_of_directory() { + let state = ScrollState { + current_focus: 19, + last_focus: Some(18), + skipped_rows: 15, + initial_preview_cushion: 5, + }; + + let height = 5; + let total = 20; + let vimlike_scrolling = true; + + let result = state.calc_skipped_rows(height, total, vimlike_scrolling); + assert_eq!(result, 15); + } + + #[test] + fn test_calc_skipped_rows_scrolling_down() { + let state = ScrollState { + current_focus: 12, + last_focus: Some(10), + skipped_rows: 10, + initial_preview_cushion: 5, + }; + + let height = 5; + let total = 20; + let vimlike_scrolling = true; + + let result = state.calc_skipped_rows(height, total, vimlike_scrolling); + assert_eq!(result, 10); + } + + #[test] + fn test_calc_skipped_rows_scrolling_up() { + let state = ScrollState { + current_focus: 8, + last_focus: Some(10), + skipped_rows: 10, + initial_preview_cushion: 5, + }; + + let height = 5; + let total = 20; + let vimlike_scrolling = true; + + let result = state.calc_skipped_rows(height, total, vimlike_scrolling); + assert_eq!(result, 7); + } + + // Add more tests for other scenarios... +} diff --git a/src/init.lua b/src/init.lua index a36ddb7..48a5f54 100644 --- a/src/init.lua +++ b/src/init.lua @@ -91,6 +91,11 @@ xplr.config.general.enable_recover_mode = false -- Type: boolean xplr.config.general.hide_remaps_in_help_menu = false +-- Set it to `true` if you want vim-like scrolling. +-- +-- Type: boolean +xplr.config.general.vimlike_scrolling = false + -- Set it to `true` if you want the cursor to stay in the same position when -- the focus is on the first path and you navigate to the previous path -- (by pressing `up`/`k`), or when the focus is on the last path and you diff --git a/src/runner.rs b/src/runner.rs index d4583a8..90c5b53 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -89,7 +89,7 @@ fn call( let focus_index = app .directory_buffer .as_ref() - .map(|d| d.focus) + .map(|d| d.scroll_state.get_focus()) .unwrap_or_default() .to_string(); @@ -279,7 +279,10 @@ impl Runner { app.explorer_config.clone(), app.pwd.clone().into(), self.focused_path, - app.directory_buffer.as_ref().map(|d| d.focus).unwrap_or(0), + app.directory_buffer + .as_ref() + .map(|d| d.scroll_state.get_focus()) + .unwrap_or(0), tx_msg_in.clone(), ); tx_pwd_watcher.send(app.pwd.clone())?; @@ -430,7 +433,7 @@ impl Runner { .map(|n| n.relative_path.clone().into()), app.directory_buffer .as_ref() - .map(|d| d.focus) + .map(|d| d.scroll_state.get_focus()) .unwrap_or(0), tx_msg_in.clone(), ); @@ -445,7 +448,7 @@ impl Runner { .map(|n| n.relative_path.clone().into()), app.directory_buffer .as_ref() - .map(|d| d.focus) + .map(|d| d.scroll_state.get_focus()) .unwrap_or(0), tx_msg_in.clone(), ); @@ -493,7 +496,7 @@ impl Runner { } // UI - terminal.draw(|f| ui::draw(f, &app, &lua))?; + terminal.draw(|f| ui::draw(f, &mut app, &lua))?; } EnableMouse => { diff --git a/src/ui.rs b/src/ui.rs index 0d3dda0..2f62610 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -722,7 +722,7 @@ fn draw_table( f: &mut Frame, screen_size: TuiRect, layout_size: TuiRect, - app: &app::App, + app: &mut app::App, lua: &Lua, ) { let panel_config = &app.config.general.panel_ui; @@ -735,15 +735,20 @@ fn draw_table( let rows = app .directory_buffer - .as_ref() + .as_mut() .map(|dir| { + dir.scroll_state.skipped_rows = dir.scroll_state.calc_skipped_rows( + height, + dir.total, + app.config.general.vimlike_scrolling, + ); dir.nodes .iter() .enumerate() - .skip(height * (dir.focus / height.max(1))) + .skip(dir.scroll_state.skipped_rows) .take(height) .map(|(index, node)| { - let is_focused = dir.focus == index; + let is_focused = dir.scroll_state.get_focus() == index; let is_selected = app .selection @@ -772,9 +777,13 @@ fn draw_table( let node_type = app_config.node_types.get(node); let (relative_index, is_before_focus, is_after_focus) = - match dir.focus.cmp(&index) { - Ordering::Greater => (dir.focus - index, true, false), - Ordering::Less => (index - dir.focus, false, true), + match dir.scroll_state.get_focus().cmp(&index) { + Ordering::Greater => { + (dir.scroll_state.get_focus() - index, true, false) + } + Ordering::Less => { + (index - dir.scroll_state.get_focus(), false, true) + } Ordering::Equal => (0, false, false), }; @@ -1284,7 +1293,7 @@ pub fn draw_dynamic( f: &mut Frame, screen_size: TuiRect, layout_size: TuiRect, - app: &app::App, + app: &mut app::App, func: &str, lua: &Lua, ) { @@ -1308,7 +1317,7 @@ pub fn draw_static( f: &mut Frame, screen_size: TuiRect, layout_size: TuiRect, - app: &app::App, + app: &mut app::App, panel: CustomPanel, _lua: &Lua, ) { @@ -1402,7 +1411,7 @@ pub fn draw_layout( f: &mut Frame, screen_size: TuiRect, layout_size: TuiRect, - app: &app::App, + app: &mut app::App, lua: &Lua, ) { match layout { @@ -1493,7 +1502,7 @@ pub fn draw_layout( } } -pub fn draw(f: &mut Frame, app: &app::App, lua: &Lua) { +pub fn draw(f: &mut Frame, app: &mut app::App, lua: &Lua) { let screen_size = f.size(); let layout = app.mode.layout.as_ref().unwrap_or(&app.layout).to_owned();