bbc_news_cli/
events.rs

1use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
2use std::time::Duration;
3
4use crate::app::{App, AppMode};
5use crate::config::Config;
6use crate::feeds::get_all_feeds;
7
8pub enum AppAction {
9    None,
10    Refresh,
11    FeedChanged,
12    Resize,
13}
14
15pub fn handle_events(app: &mut App, config: &Config) -> anyhow::Result<AppAction> {
16    if event::poll(Duration::from_millis(100))? {
17        match event::read()? {
18            Event::Key(key) if key.kind == KeyEventKind::Press => {
19                return handle_key_event(app, key, config);
20            }
21            Event::Resize(_, _) => {
22                return Ok(AppAction::Resize);
23            }
24            _ => {}
25        }
26    }
27    Ok(AppAction::None)
28}
29
30fn handle_key_event(app: &mut App, key: KeyEvent, config: &Config) -> anyhow::Result<AppAction> {
31    let kb = &config.keybindings;
32
33    // Handle help menu mode separately
34    if app.mode == AppMode::Help {
35        match key.code {
36            KeyCode::Char('?') | KeyCode::Esc => app.toggle_help_menu(),
37            _ => {}
38        }
39        return Ok(AppAction::None);
40    }
41
42    // Handle feed menu mode separately
43    if app.mode == AppMode::FeedMenu {
44        match key.code {
45            KeyCode::Char('j') | KeyCode::Down => {
46                let feeds = get_all_feeds();
47                app.feed_menu_next(feeds.len());
48            }
49            KeyCode::Char('k') | KeyCode::Up => app.feed_menu_previous(),
50            KeyCode::Enter => {
51                let feeds = get_all_feeds();
52                if let Some(feed) = feeds.get(app.feed_menu_selected) {
53                    app.select_feed(feed.clone());
54                    return Ok(AppAction::FeedChanged);
55                }
56            }
57            KeyCode::Char('f') | KeyCode::Esc => app.toggle_feed_menu(),
58            _ => {}
59        }
60        return Ok(AppAction::None);
61    }
62
63    // ARTICLE VIEW MODE: Handle scrolling within article
64    if app.show_full_article {
65        match key.code {
66            KeyCode::Up | KeyCode::Char('k') => app.scroll_article_up(),
67            KeyCode::Down | KeyCode::Char('j') => app.scroll_article_down(),
68            KeyCode::Char(c) if c == kb.open => app.open_selected()?,
69            KeyCode::Enter | KeyCode::Tab | KeyCode::Esc => app.toggle_article_view(),
70            _ => {}
71        }
72        return Ok(AppAction::None);
73    }
74
75    // PREVIEW PANE PROTECTION: Block scrolling keys when preview is open
76    // This prevents Ghostty event buffering catastrophe (scroll storm detection is backup)
77    if app.show_preview && !app.show_full_article {
78        let is_scroll_key = matches!(
79            key.code,
80            KeyCode::Down | KeyCode::Up
81        ) || matches!(
82            key.code,
83            KeyCode::Char(c) if c == kb.scroll_down || c == kb.scroll_up || c == kb.scroll_bottom || c == kb.latest
84        );
85
86        if is_scroll_key {
87            // Drain all buffered scroll events to prevent delay when Tab is pressed
88            // This prevents 5+ second delays when keys were held down
89            while event::poll(Duration::ZERO)? {
90                if let Event::Key(next_key) = event::read()? {
91                    if next_key.kind == KeyEventKind::Press {
92                        // Check if this is also a scroll key
93                        let is_next_scroll = matches!(
94                            next_key.code,
95                            KeyCode::Down | KeyCode::Up
96                        ) || matches!(
97                            next_key.code,
98                            KeyCode::Char(c) if c == kb.scroll_down || c == kb.scroll_up || c == kb.scroll_bottom || c == kb.latest
99                        );
100
101                        if !is_next_scroll {
102                            // Found a non-scroll key (like Tab!), process it immediately
103                            return handle_key_event(app, next_key, config);
104                        }
105                        // Otherwise it's another scroll key, discard and continue draining
106                    }
107                }
108            }
109            // All buffered scroll events drained, return
110            return Ok(AppAction::None);
111        }
112    }
113
114    // Normal mode key handling
115    match key.code {
116        KeyCode::Char(c) if c == kb.quit => app.quit(),
117        KeyCode::Char(c) if c == kb.scroll_down => app.next(),
118        KeyCode::Char(c) if c == kb.scroll_up => app.previous(),
119        KeyCode::Char(c) if c == kb.scroll_bottom => app.scroll_to_bottom(),
120        KeyCode::Char(c) if c == kb.open => app.open_selected()?,
121        KeyCode::Char(c) if c == kb.open_new_tab => app.open_selected_new_tab()?,
122        KeyCode::Char(c) if c == kb.latest => app.scroll_to_top(),
123        KeyCode::Char(c) if c == kb.refresh => return Ok(AppAction::Refresh),
124        KeyCode::Char('f') => app.toggle_feed_menu(),
125        KeyCode::Char('s') => app.cycle_sort_order(),
126        KeyCode::Char('t') => app.toggle_date_format(),
127        KeyCode::Char('T') => app.cycle_theme(),
128        KeyCode::Char('p') => app.cycle_image_protocol(),
129        KeyCode::Char('?') => app.toggle_help_menu(),
130        KeyCode::Char('a') => app.fetch_and_show_article(),
131        KeyCode::Char(' ') => {
132            // Jump to current ticker article
133            if app.jump_to_ticker_article() {
134                return Ok(AppAction::FeedChanged);
135            }
136        }
137        KeyCode::Tab => app.toggle_preview(),
138        KeyCode::Enter => app.fetch_and_show_article(),
139        // Also support arrow keys
140        KeyCode::Down => app.next(),
141        KeyCode::Up => app.previous(),
142        KeyCode::Esc => app.quit(),
143        _ => {}
144    }
145
146    Ok(AppAction::None)
147}