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

Co-authored-by: sxyazi <sxyazi@gmail.com>
This commit is contained in:
SpiritCroc 2026-06-24 04:42:20 -05:00 committed by GitHub
parent 581886a355
commit b1ee2e72ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 47 additions and 15 deletions

View file

@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
- Drag and drop ([#4005])
- Bulk create ([#3793])
- Make help menu a command palette ([#4074])
- H/M/L Vim-like motion for moving cursor relative to viewport ([#3970])
- Dynamic keymap Lua API ([#4031])
- New `ui.Input` element ([#4040])
- Image preview with Überzug++ on Niri ([#3990])
@ -1749,6 +1750,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
[#3906]: https://github.com/sxyazi/yazi/pull/3906
[#3934]: https://github.com/sxyazi/yazi/pull/3934
[#3943]: https://github.com/sxyazi/yazi/pull/3943
[#3970]: https://github.com/sxyazi/yazi/pull/3970
[#3989]: https://github.com/sxyazi/yazi/pull/3989
[#3990]: https://github.com/sxyazi/yazi/pull/3990
[#4005]: https://github.com/sxyazi/yazi/pull/4005

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)
}