From 6c458ca31806868d0e1ce1d94d09ef9c6beb81d8 Mon Sep 17 00:00:00 2001 From: SpiritCroc Date: Sun, 17 May 2026 15:00:42 +0200 Subject: [PATCH 1/2] 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" }, ``` --- yazi-actor/src/confirm/arrow.rs | 2 +- yazi-actor/src/mgr/tab_swap.rs | 2 +- yazi-actor/src/spot/arrow.rs | 2 +- yazi-actor/src/tasks/arrow.rs | 2 +- yazi-widgets/src/scrollable.rs | 30 +++++++++++++++++++++++------- yazi-widgets/src/step.rs | 22 ++++++++++++++++++---- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/yazi-actor/src/confirm/arrow.rs b/yazi-actor/src/confirm/arrow.rs index 3318b3fc3..bc75ccbb8 100644 --- a/yazi-actor/src/confirm/arrow.rs +++ b/yazi-actor/src/confirm/arrow.rs @@ -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)); } diff --git a/yazi-actor/src/mgr/tab_swap.rs b/yazi-actor/src/mgr/tab_swap.rs index f2a37e816..b08b6deec 100644 --- a/yazi-actor/src/mgr/tab_swap.rs +++ b/yazi-actor/src/mgr/tab_swap.rs @@ -16,7 +16,7 @@ impl Actor for TabSwap { fn act(cx: &mut Ctx, form: Self::Form) -> Result { 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!(); } diff --git a/yazi-actor/src/spot/arrow.rs b/yazi-actor/src/spot/arrow.rs index d7f859034..c8725e402 100644 --- a/yazi-actor/src/spot/arrow.rs +++ b/yazi-actor/src/spot/arrow.rs @@ -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); }; diff --git a/yazi-actor/src/tasks/arrow.rs b/yazi-actor/src/tasks/arrow.rs index ebcf200e5..474e29691 100644 --- a/yazi-actor/src/tasks/arrow.rs +++ b/yazi-actor/src/tasks/arrow.rs @@ -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)); } diff --git a/yazi-widgets/src/scrollable.rs b/yazi-widgets/src/scrollable.rs index 4fde11c55..31c3f97d5 100644 --- a/yazi-widgets/src/scrollable.rs +++ b/yazi-widgets/src/scrollable.rs @@ -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) -> 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) diff --git a/yazi-widgets/src/step.rs b/yazi-widgets/src/step.rs index a9ec6d866..037928a4b 100644 --- a/yazi-widgets/src/step.rs +++ b/yazi-widgets/src/step.rs @@ -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) } From abba72178bd70267c03b68d311313c1acaf7d76d Mon Sep 17 00:00:00 2001 From: sxyazi Date: Wed, 24 Jun 2026 17:41:10 +0800 Subject: [PATCH 2/2] Add changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e5097151..e55d5a503 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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