Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down
27 changes: 22 additions & 5 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
12 changes: 10 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,16 @@ async fn handle_story_action(
bookmark_path: &mut Option<PathBuf>,
) -> 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() {
Expand Down
85 changes: 85 additions & 0 deletions src/ui/ui_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading