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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
46 changes: 42 additions & 4 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
}
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -153,5 +187,9 @@ mod tests {
handle_comments_key(KeyCode::Esc),
CommentAction::Back
));
assert!(matches!(
handle_comments_key(KeyCode::Char(' ')),
CommentAction::PageDown
));
}
}
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion src/reader/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions src/reader_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 30 additions & 2 deletions src/ui/ui_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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')]),
Expand All @@ -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] {
Expand Down Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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"));
}
10 changes: 9 additions & 1 deletion src/ui/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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| {
Expand Down Expand Up @@ -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(
Expand Down
Loading