diff --git a/README.md b/README.md index 458901f..0713b8c 100644 --- a/README.md +++ b/README.md @@ -110,8 +110,9 @@ Keys are case-sensitive: `s` saves a story, `S` controls its summary; `o` opens |---|---| | `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 | +| `g` / `G` | First / last story on the page | +| `n` / `→` / `Space` / `PgDn` | Next page | +| `p` / `←` / `PgUp` | Previous page | | `1` / `2` / `3` | Top / New / Best stories | | `4` | Saved stories | | `s` | Save or unsave selected story | diff --git a/src/app.rs b/src/app.rs index c551308..954c5ac 100644 --- a/src/app.rs +++ b/src/app.rs @@ -267,6 +267,12 @@ impl App { } } + /// Jump straight to an index without the glide (g/G page-edge jumps snap). + pub fn jump_selection(&mut self, index: usize) { + self.selection_glide = None; + self.selected_index = index.min(self.stories.len().saturating_sub(1)); + } + fn move_selection(&mut self, new_index: usize) { let now = Instant::now(); let from = self.selection_glide_position(now); diff --git a/src/event.rs b/src/event.rs index 9be459b..2a39fe2 100644 --- a/src/event.rs +++ b/src/event.rs @@ -8,10 +8,14 @@ pub fn handle_stories_key(key: KeyCode) -> StoryAction { // Navigation KeyCode::Char('j') | KeyCode::Down => StoryAction::NextStory, KeyCode::Char('k') | KeyCode::Up => StoryAction::PrevStory, + KeyCode::Char('g') => StoryAction::FirstStory, + KeyCode::Char('G') => StoryAction::LastStory, // Pagination - KeyCode::Char('n') | KeyCode::Right => StoryAction::NextPage, - KeyCode::Char('p') | KeyCode::Left => StoryAction::PrevPage, + KeyCode::Char('n') | KeyCode::Right | KeyCode::Char(' ') | KeyCode::PageDown => { + StoryAction::NextPage + } + KeyCode::Char('p') | KeyCode::Left | KeyCode::PageUp => StoryAction::PrevPage, // Story type KeyCode::Char('1') => StoryAction::SetType(crate::app::StoryType::Top), @@ -27,7 +31,9 @@ pub fn handle_stories_key(key: KeyCode) -> StoryAction { // UI KeyCode::Char('?') => StoryAction::ToggleHelp, - KeyCode::Char('q') | KeyCode::Esc => StoryAction::Quit, + // Esc means "back" in the reader; at the feed it stays inert so it can't + // quit the app by reflex. q is the quit key. + KeyCode::Char('q') => StoryAction::Quit, _ => StoryAction::None, } @@ -44,7 +50,7 @@ pub fn handle_comments_key(key: KeyCode) -> CommentAction { KeyCode::Char('u') => CommentAction::Parent, KeyCode::Char('g') => CommentAction::FirstComment, KeyCode::Char('G') => CommentAction::LastComment, - KeyCode::PageDown => CommentAction::PageDown, + KeyCode::PageDown | KeyCode::Char(' ') => CommentAction::PageDown, KeyCode::PageUp => CommentAction::PageUp, // Expand/collapse @@ -72,6 +78,8 @@ pub fn handle_comments_key(key: KeyCode) -> CommentAction { pub enum StoryAction { NextStory, PrevStory, + FirstStory, + LastStory, NextPage, PrevPage, SetType(crate::app::StoryType), @@ -125,6 +133,32 @@ mod tests { handle_stories_key(KeyCode::Char('q')), StoryAction::Quit )); + // Esc no longer quits at the feed; it stays inert so it can't kill the + // app by reflex (it means "back" in the reader). + assert!(matches!( + handle_stories_key(KeyCode::Esc), + StoryAction::None + )); + assert!(matches!( + handle_stories_key(KeyCode::Char('g')), + StoryAction::FirstStory + )); + assert!(matches!( + handle_stories_key(KeyCode::Char('G')), + StoryAction::LastStory + )); + assert!(matches!( + handle_stories_key(KeyCode::Char(' ')), + StoryAction::NextPage + )); + assert!(matches!( + handle_stories_key(KeyCode::PageDown), + StoryAction::NextPage + )); + assert!(matches!( + handle_stories_key(KeyCode::PageUp), + StoryAction::PrevPage + )); } #[test] @@ -153,5 +187,9 @@ mod tests { handle_comments_key(KeyCode::Esc), CommentAction::Back )); + assert!(matches!( + handle_comments_key(KeyCode::Char(' ')), + CommentAction::PageDown + )); } } diff --git a/src/main.rs b/src/main.rs index 9f21ad6..59a672f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -290,6 +290,8 @@ async fn handle_story_action( request_stories(app, tx, service, false); } } + StoryAction::FirstStory => app.jump_selection(0), + StoryAction::LastStory => app.jump_selection(app.stories.len().saturating_sub(1)), StoryAction::NextPage => { app.next_page(); if app.preview_notice.is_none() { diff --git a/src/reader/settings.rs b/src/reader/settings.rs index 1a9e130..39fcab9 100644 --- a/src/reader/settings.rs +++ b/src/reader/settings.rs @@ -21,7 +21,7 @@ pub struct Settings { retry: bool, } impl Reader { - pub(super) fn edit_settings(&mut self) { + pub fn edit_settings(&mut self) { let retry = self .active .and_then(|id| self.readings.get(&id)) diff --git a/src/reader_actions.rs b/src/reader_actions.rs index 35fafb2..c6cfc15 100644 --- a/src/reader_actions.rs +++ b/src/reader_actions.rs @@ -100,6 +100,10 @@ pub async fn handle( } return Ok(true); } + if matches!(app.view, View::Stories) && key == KeyCode::Char('M') { + reader.edit_settings(); + return Ok(true); + } if matches!(app.view, View::Stories) && matches!( key, diff --git a/src/ui/ui_tests.rs b/src/ui/ui_tests.rs index 953ad2c..11dfbd2 100644 --- a/src/ui/ui_tests.rs +++ b/src/ui/ui_tests.rs @@ -298,7 +298,9 @@ fn stories_help_keys_match_handlers_and_use_accent() { let theme = crate::theme::Theme::TRUE_DARK; let entries: &[(&str, &[KeyCode])] = &[ ("j/k/↑/↓", &[Char('j'), Char('k'), Up, Down]), + ("g/G", &[Char('g'), Char('G')]), ("n/p/→/←", &[Char('n'), Char('p'), Right, Left]), + ("Space/PgDn", &[Char(' '), PageDown]), ("1", &[Char('1')]), ("2", &[Char('2')]), ("3", &[Char('3')]), @@ -308,7 +310,7 @@ fn stories_help_keys_match_handlers_and_use_accent() { ("c", &[Char('c')]), ("s", &[Char('s')]), ("r", &[Char('r')]), - ("q/Esc", &[Char('q'), Esc]), + ("q", &[Char('q')]), ("?", &[Char('?')]), ]; for width in [60, 77, 78, 80, 100] { @@ -337,7 +339,7 @@ fn stories_help_keys_match_handlers_and_use_accent() { ); } let (_, first) = position(frame.buffer, "j/k/↑/↓"); - let (_, second) = position(frame.buffer, "n/p/→/←"); + let (_, second) = position(frame.buffer, "g/G"); assert_eq!(second - first, u16::from(width < 78)); } } @@ -873,3 +875,29 @@ fn page_advance_blocked_during_same_page_refresh_but_selection_still_moves() { assert!(!app.next_story(), "mid-list j still works during a refresh"); assert_eq!(app.selected_index, 1); } + +#[test] +fn g_G_jump_to_page_edges_without_glide() { + let mut app = feed(); + app.next_story(); + assert!(app.selection_glide.is_some(), "j starts a glide"); + app.jump_selection(app.stories.len() - 1); + assert_eq!(app.selected_index, app.stories.len() - 1); + assert!(app.selection_glide.is_none(), "g/G snap instead of gliding"); + app.jump_selection(0); + assert_eq!(app.selected_index, 0); +} + +#[test] +fn status_bar_hints_page_flow_at_page_edges() { + let mut app = feed(); + let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap(); + app.jump_selection(app.stories.len() - 1); + let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); + let status = buffer_text(frame.buffer); + assert!(status.contains("next page"), "last story must hint j → next page: {status}"); + // Not at an edge: no page-flow hint. + let mut app = feed(); + let frame = terminal.draw(|f| render(f, &mut app, 0)).unwrap(); + assert!(!buffer_text(frame.buffer).contains("next page")); +} diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 79adad6..873c74f 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -191,7 +191,9 @@ pub fn render_help(f: &mut Frame, theme: Theme, in_comments: bool, opened: Optio "STORIES", &[ ("j/k/↑/↓", "Select story"), + ("g/G", "First/last story"), ("n/p/→/←", "Page"), + ("Space/PgDn", "Next page"), ("1", "Top"), ("2", "New"), ("3", "Best"), @@ -203,7 +205,7 @@ pub fn render_help(f: &mut Frame, theme: Theme, in_comments: bool, opened: Optio ("r", "Refresh/retry"), ], ), - ("ANYWHERE", &[("q/Esc", "Quit"), ("?", "Close")]), + ("ANYWHERE", &[("q", "Quit"), ("?", "Close")]), ] }; render_modal_body(f, theme, opened, "Keys", |width, height| { @@ -318,6 +320,12 @@ pub fn render_stories_status(area: Rect, app: &App, _tick: usize) -> Paragraph<' ]; if app.snapshot.is_none() && app.displayed_story_context().0 != crate::app::StoryType::Saved { hints.push(("n/p", "page")); + if app.selected_index == 0 && app.current_page > 1 { + hints.push(("k", "prev page")); + } + if !app.stories.is_empty() && app.selected_index == app.stories.len() - 1 { + hints.push(("j", "next page")); + } } hints.extend([("c", "discussion"), ("q", "quit")]); let line = hint_line(