From a963c8ba2df1957cbd392844603ec6af9530a1ad Mon Sep 17 00:00:00 2001 From: chris ha Date: Thu, 5 Nov 2026 16:21:11 +0000 Subject: [PATCH] Land k-at-top on the last story of the previous page Going back with k used to land on the previous page's first story because position memory is per-feed. j/k-triggered page switches now carry an explicit landing target (Start for j, End for k) that set_stories_for applies when the new page loads; manual n/p keep the existing position-restore behavior. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- README.md | 2 +- src/app.rs | 37 +++++++++++++++++++++++++++++++++++-- src/ui/ui_tests.rs | 29 +++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6f3283f..458901f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Set `HN_REDUCED_MOTION=1` to disable visual animations; ANSI and monochrome pale ## Read a story -Use `j/k` or the arrow keys to select a story. Press `1`, `2`, or `3` for Top, New, or Best; `4` opens Saved. Use `n/p` to change pages — or press `j` past the last story to continue on the next page's first story, and `k` at the top to go back a page (further `j/k` wait while the page loads). +Use `j/k` or the arrow keys to select a story. Press `1`, `2`, or `3` for Top, New, or Best; `4` opens Saved. Use `n/p` to change pages — or press `j` past the last story to continue on the next page's first story, and `k` at the top to go back to the last story of the previous page (further `j/k` wait while the page loads). Press `o` or Enter to open the discussion and its article. Press `c` to read the discussion without opening a new article pane. If a companion pane is already open, it follows the selected story. `O` always opens the story in your default browser. diff --git a/src/app.rs b/src/app.rs index c000e6d..c551308 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,6 +10,15 @@ use std::{ /// because this is the highest-frequency motion in the app). pub const SELECTION_GLIDE: Duration = Duration::from_millis(110); +/// Where the selection lands when a j/k-triggered page switch finishes loading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PageLanding { + /// j past the last story: first story of the next page. + Start, + /// k at the first story: last story of the previous page. + End, +} + #[cfg(test)] mod app_tests; mod comment_refresh; @@ -95,6 +104,8 @@ pub struct App { /// Outgoing list (stories, selection, scroll) retained during the dip's /// out-phase so the old list fades away instead of the new one popping in pub outgoing_feed: Option<(Vec, usize, usize)>, + /// Landing target for a j/k-triggered page switch, consumed by set_stories_for + pub page_landing: Option, /// Current page number pub current_page: u32, /// Comments for current story @@ -169,6 +180,7 @@ impl App { selection_glide: None, feed_switched: None, outgoing_feed: None, + page_landing: None, current_page: 1, comments: Vec::new(), visible_comment_paths: Vec::new(), @@ -224,7 +236,11 @@ impl App { } else { let page = self.current_page; self.next_page(); - self.current_page != page + let advanced = self.current_page != page; + if advanced { + self.page_landing = Some(PageLanding::Start); + } + advanced } } @@ -243,7 +259,11 @@ impl App { } else { let page = self.current_page; self.prev_page(); - self.current_page != page + let changed = self.current_page != page; + if changed { + self.page_landing = Some(PageLanding::End); + } + changed } } @@ -512,6 +532,19 @@ impl App { self.selected_index, self.story_scroll, )); + // A j/k-triggered page switch lands on the page edge instead + // of restoring a remembered position. + match self.page_landing.take() { + Some(PageLanding::Start) => { + self.selected_index = 0; + self.story_scroll = 0; + } + Some(PageLanding::End) => { + self.selected_index = stories.len().saturating_sub(1); + self.story_scroll = self.selected_index; + } + None => {} + } } } self.stories_for = Some((story_type, page)); diff --git a/src/ui/ui_tests.rs b/src/ui/ui_tests.rs index 722475f..953ad2c 100644 --- a/src/ui/ui_tests.rs +++ b/src/ui/ui_tests.rs @@ -785,9 +785,34 @@ fn k_at_page_top_returns_to_previous_page() { (1..=12).map(story_item).collect(), ); assert_eq!( - app.selected_index, 0, - "previous page loads like a manual p, starting at its first story" + app.selected_index, + app.stories.len() - 1, + "going back with k lands at the bottom of the previous page" ); + // And j at that bottom flows forward again, landing on the next page's first story. + assert!(app.next_story()); + app.set_stories_for( + StoryType::Best, + 2, + (20..=31).map(story_item).collect(), + ); + assert_eq!(app.selected_index, 0, "j lands on the first story of the next page"); +} + +#[test] +fn manual_page_keys_keep_position_restore_behavior() { + use crate::app::StoryType; + let mut app = feed(); + app.selected_index = app.stories.len() - 1; + app.next_page(); + app.set_stories_for( + StoryType::Best, + 2, + (20..=31).map(story_item).collect(), + ); + assert_eq!(app.selected_index, 0, "manual n starts at the first story"); + // No page_landing was set by n/p, so nothing overrides the restore rules. + assert!(app.page_landing.is_none()); } #[test]