feat: H/M/L Vim-like motion for moving cursor relative to viewport

Introduce a new `vp` step unit, that allows you to move the
cursor to relative position based on the items currently rendered on
screen, similar as H/M/L ("high", "middle", "low") would do in Vim.

Not added to default keybinding config since there are some conflicts,
but you can put following to override:

```
{ on = "H", run = "arrow 0vp",   desc = "High: Move cursor to visible viewport top" },
{ on = "M", run = "arrow 50vp",  desc = "Middle: Move cursor to visible viewport center" },
{ on = "L", run = "arrow 100vp", desc = "Low: Move cursor to visible viewport bottom" },
```
This commit is contained in:
SpiritCroc 2026-05-17 15:00:42 +02:00 committed by sxyazi
parent 581886a355
commit 6c458ca318
No known key found for this signature in database
6 changed files with 45 additions and 15 deletions

View file

@ -19,7 +19,7 @@ impl Actor for Arrow {
let len = confirm.list.line_count(area.width);
let old = confirm.offset;
confirm.offset = form.step.add(confirm.offset, len, area.height as _);
confirm.offset = form.step.add(confirm.offset, len, area.height as _, 0, 0);
succ!(render!(old != confirm.offset));
}

View file

@ -16,7 +16,7 @@ impl Actor for TabSwap {
fn act(cx: &mut Ctx, form: Self::Form) -> Result<Data> {
let tabs = cx.tabs_mut();
let new = form.step.add(tabs.cursor, tabs.len(), 0);
let new = form.step.add(tabs.cursor, tabs.len(), 0, 0, 0);
if new == tabs.cursor {
succ!();
}

View file

@ -16,7 +16,7 @@ impl Actor for Arrow {
let spot = &mut cx.tab_mut().spot;
let Some(lock) = &mut spot.lock else { succ!() };
let new = form.step.add(spot.skip, lock.len().unwrap_or(u16::MAX as _), 0);
let new = form.step.add(spot.skip, lock.len().unwrap_or(u16::MAX as _), 0, 0, 0);
let Some(old) = lock.selected() else {
return act!(mgr:spot, cx, new);
};

View file

@ -17,7 +17,7 @@ impl Actor for Arrow {
let tasks = &mut cx.tasks;
let old = tasks.cursor;
tasks.cursor = form.step.add(tasks.cursor, tasks.snaps.len(), Tasks::limit());
tasks.cursor = form.step.add(tasks.cursor, tasks.snaps.len(), Tasks::limit(), 0, 0);
succ!(render!(tasks.cursor != old));
}

View file

@ -7,19 +7,35 @@ pub trait Scrollable {
fn cursor_mut(&mut self) -> &mut usize;
fn offset_mut(&mut self) -> &mut usize;
/// End of the visible window (exclusive)
fn visible_end(&mut self) -> usize { self.total().min(*self.offset_mut() + self.limit()) }
/// Cursor is past the bottom safe zone
fn needs_scroll_down(&mut self, cursor: usize) -> bool {
cursor >= self.visible_end().saturating_sub(self.scrolloff())
}
/// Cursor is in the top scrolloff zone
fn needs_scroll_up(&mut self, cursor: usize) -> bool {
cursor < *self.offset_mut() + self.scrolloff()
}
fn scroll(&mut self, step: impl Into<Step>) -> bool {
let new = step.into().add(*self.cursor_mut(), self.total(), self.limit());
if new > *self.cursor_mut() { self.next(new) } else { self.prev(new) }
let old = *self.cursor_mut();
let new =
step.into().add(old, self.total(), self.limit(), *self.offset_mut(), self.scrolloff());
if new > old { self.next(new) } else { self.prev(new) }
}
fn next(&mut self, n_cur: usize) -> bool {
let (o_cur, o_off) = (*self.cursor_mut(), *self.offset_mut());
let (total, limit, scrolloff) = (self.total(), self.limit(), self.scrolloff());
let (total, limit) = (self.total(), self.limit());
let n_off = if n_cur < total.min(o_off + limit).saturating_sub(scrolloff) {
o_off.min(total.saturating_sub(1))
} else {
let n_off = if self.needs_scroll_down(n_cur) {
total.saturating_sub(limit).min(o_off + n_cur - o_cur)
} else {
o_off.min(total.saturating_sub(1))
};
*self.cursor_mut() = n_cur;
@ -30,7 +46,7 @@ pub trait Scrollable {
fn prev(&mut self, n_cur: usize) -> bool {
let (o_cur, o_off) = (*self.cursor_mut(), *self.offset_mut());
let n_off = if n_cur < o_off + self.scrolloff() {
let n_off = if self.needs_scroll_up(n_cur) {
o_off.saturating_sub(o_cur - n_cur)
} else {
self.total().saturating_sub(1).min(o_off)

View file

@ -10,6 +10,7 @@ pub enum Step {
Next,
Offset(isize),
Percent(i8),
Vp(i8),
}
impl Default for Step {
@ -30,6 +31,7 @@ impl FromStr for Step {
"prev" => Self::Prev,
"next" => Self::Next,
s if s.ends_with('%') => Self::Percent(s[..s.len() - 1].parse()?),
s if s.ends_with("vp") => Self::Vp(s[..s.len() - 2].parse()?),
s => Self::Offset(s.parse()?),
})
}
@ -76,7 +78,7 @@ impl<'de> Deserialize<'de> for Step {
}
impl Step {
pub fn add(self, pos: usize, len: usize, limit: usize) -> usize {
pub fn add(self, pos: usize, len: usize, limit: usize, offset: usize, scrolloff: usize) -> usize {
if len == 0 {
return 0;
}
@ -84,6 +86,20 @@ impl Step {
let off = match self {
Self::Top => return 0,
Self::Bot => return len - 1,
Self::Vp(n) if limit == 0 => n as isize * len as isize / 100,
Self::Vp(n) => {
let end = len.min(offset + limit);
let Some(count) = end.checked_sub(offset + 1) else { return 0 };
let scrolloff = scrolloff.min(count / 2);
// Clamp relative position in window to not reach into any scrolloff region.
// Still allow reaching the real list start and end if already visible.
let target = offset.saturating_add_signed(n as isize * count as isize / 100);
let min = if offset == 0 { 0 } else { offset + scrolloff };
let max = end - 1 - if end == len { 0 } else { scrolloff };
target.clamp(min, max) as isize - pos as isize
}
Self::Prev => -1,
Self::Next => 1,
Self::Offset(n) => n,
@ -93,10 +109,8 @@ impl Step {
if matches!(self, Self::Prev | Self::Next) {
off.saturating_add_unsigned(pos).rem_euclid(len as _) as _
} else if off >= 0 {
pos.saturating_add_signed(off)
} else {
pos.saturating_sub(off.unsigned_abs())
pos.saturating_add_signed(off)
}
.min(len - 1)
}