Skip to content

Commit

Permalink
Adds Vim-Like Scrolling to XPLR (#704)
Browse files Browse the repository at this point in the history
- Added through a setting `vimlike_scrolling` which is turned off by
default
- A hard-coded _(for now)_ cushion of `5` lines that allows for
previewing the next lines while scrolling
- A separate struct `ScrollState` with getters and setters for the
`current_focus` field to disallow setting the field without updating the
`last_focus` field
  • Loading branch information
sayanarijit authored Apr 10, 2024
2 parents 96ffe86 + 976530b commit c1bb251
Show file tree
Hide file tree
Showing 8 changed files with 274 additions and 43 deletions.
2 changes: 1 addition & 1 deletion benches/criterion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
})
});

Expand Down
6 changes: 6 additions & 0 deletions docs/en/src/general-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 26 additions & 22 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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() {
Expand All @@ -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());
Expand All @@ -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)
Expand Down Expand Up @@ -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());
}
Expand All @@ -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)
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -1238,7 +1241,8 @@ impl App {
fn focus_by_index(mut self, index: usize) -> Result<Self> {
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());
}
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
209 changes: 205 additions & 4 deletions src/directory_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
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(&current_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<Node>,
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<Node>, focus: usize) -> Self {
pub fn new(parent: String, nodes: Vec<Node>, 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)
}
}

Expand All @@ -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...
}
5 changes: 5 additions & 0 deletions src/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit c1bb251

Please sign in to comment.