From ce3b9229516bac84cd9270c85874289e2af2246b Mon Sep 17 00:00:00 2001 From: chris ha Date: Thu, 5 Nov 2026 16:07:41 +0000 Subject: [PATCH] Let j/k flow across page boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At the last story of a page, j now advances to the next page (selection lands on its first story once loaded); at the first story, k goes back to the previous page. next_story/prev_story return whether the page changed so the key handler can request the new page. Snapshot previews (one page per feed) and the Saved feed are unchanged — j at the end there just shows the existing notice. README key docs updated. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- README.md | 6 ++-- src/app.rs | 27 ++++++++++++--- src/main.rs | 12 +++++-- src/ui/ui_tests.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3bde562..c95a4c2 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. +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 keep holding `j` past the last story to continue on the next page's first story, and `k` at the top to go back a page. 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. @@ -108,8 +108,8 @@ Keys are case-sensitive: `s` saves a story, `S` controls its summary; `o` opens | Key | Action | |---|---| -| `j` / `↓` | Next story | -| `k` / `↑` | Previous story | +| `j` / `↓` | Next story; at the last story, continues on the next page's first story | +| `k` / `↑` | Previous story; at the first story, goes back to the previous page | | `n` / `→` | Next page | | `p` / `←` | Previous page | | `1` / `2` / `3` | Top / New / Best stories | diff --git a/src/app.rs b/src/app.rs index e8252f4..e3d39c8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -207,17 +207,34 @@ impl App { // === Story Navigation === - /// Move to next story - pub fn next_story(&mut self) { - if !self.stories.is_empty() && self.selected_index < self.stories.len() - 1 { + /// Move to next story. At the last story of the page, continue on the next + /// page instead (the first story once it loads). Returns true when the + /// page advanced and the caller should request the new page. + pub fn next_story(&mut self) -> bool { + if self.stories.is_empty() { + return false; + } + if self.selected_index < self.stories.len() - 1 { self.move_selection(self.selected_index + 1); + false + } else { + let page = self.current_page; + self.next_page(); + self.current_page != page } } - /// Move to previous story - pub fn prev_story(&mut self) { + /// Move to previous story. At the first story of the page, go back to the + /// previous page (restoring the position left there). Returns true when + /// the page changed and the caller should request it. + pub fn prev_story(&mut self) -> bool { if self.selected_index > 0 { self.move_selection(self.selected_index - 1); + false + } else { + let page = self.current_page; + self.prev_page(); + self.current_page != page } } diff --git a/src/main.rs b/src/main.rs index e1eb8f8..9f21ad6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -280,8 +280,16 @@ async fn handle_story_action( bookmark_path: &mut Option, ) -> Result<()> { match action { - StoryAction::NextStory => app.next_story(), - StoryAction::PrevStory => app.prev_story(), + StoryAction::NextStory => { + if app.next_story() && app.preview_notice.is_none() { + request_stories(app, tx, service, false); + } + } + StoryAction::PrevStory => { + if app.prev_story() { + request_stories(app, tx, service, false); + } + } StoryAction::NextPage => { app.next_page(); if app.preview_notice.is_none() { diff --git a/src/ui/ui_tests.rs b/src/ui/ui_tests.rs index 46794da..9c035f6 100644 --- a/src/ui/ui_tests.rs +++ b/src/ui/ui_tests.rs @@ -734,3 +734,88 @@ fn first_feed_load_does_not_dip() { assert!(app.feed_switched.is_none(), "initial load snaps, no dip"); } +fn story_item(id: i32) -> crate::HNCLIItem { + crate::HNCLIItem { + id, + title: format!("Story {id}"), + url: "https://example.com".into(), + author: "reader".into(), + time: String::new(), + time_ago: "2h".into(), + score: 10, + text: String::new(), + links: vec![], + comments: Some(2), + } +} + +#[test] +fn j_at_page_end_flows_to_next_page_first_story() { + use crate::app::StoryType; + let mut app = feed(); + app.selected_index = app.stories.len() - 1; + assert!(app.next_story(), "j at the last story must advance the page"); + assert_eq!(app.current_page, 2); + // When page 2 loads, selection lands on its first story. + app.set_stories_for( + StoryType::Best, + 2, + (20..=31).map(story_item).collect(), + ); + assert_eq!(app.selected_index, 0); +} + +#[test] +fn k_at_page_top_returns_to_previous_page() { + use crate::app::StoryType; + let mut app = feed(); + app.selected_index = app.stories.len() - 1; + assert!(app.next_story(), "j at the end flows to the next page"); + app.set_stories_for( + StoryType::Best, + 2, + (20..=31).map(story_item).collect(), + ); + assert_eq!(app.selected_index, 0, "fresh page starts at the first story"); + assert!(app.prev_story(), "k at the first story must go back a page"); + assert_eq!(app.current_page, 1); + app.set_stories_for( + StoryType::Best, + 1, + (1..=12).map(story_item).collect(), + ); + assert_eq!( + app.selected_index, 0, + "previous page loads like a manual p, starting at its first story" + ); +} + +#[test] +fn jk_page_flow_stays_put_in_snapshot_and_saved() { + use crate::app::StoryType; + // Snapshot preview has one page per feed: j at the end shows the notice. + let mut app = feed(); + app.snapshot = Some(std::sync::Arc::new( + serde_json::from_str(include_str!("../../preview/fixtures/snapshot.json")).unwrap(), + )); + app.selected_index = app.stories.len() - 1; + assert!(!app.next_story()); + assert_eq!(app.current_page, 1); + assert!(app.preview_notice.is_some()); + // Saved has no pages: k at the top and j at the end do nothing. + let mut app = feed(); + app.story_type = StoryType::Saved; + app.selected_index = 0; + assert!(!app.prev_story()); + app.selected_index = app.stories.len() - 1; + assert!(!app.next_story()); + assert_eq!(app.current_page, 1); +} + +#[test] +fn j_mid_list_returns_false_and_keeps_selection_on_page() { + let mut app = feed(); + assert!(!app.next_story()); + assert_eq!(app.selected_index, 1); + assert_eq!(app.current_page, 1); +}