bbc_news_cli/
app.rs

1use crate::feeds::{Feed, get_default_feed};
2use crate::theme::Theme;
3use std::time::{Instant, Duration};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, PartialEq)]
7pub enum AppMode {
8    Normal,
9    FeedMenu,
10    Preview,
11    Help,
12}
13
14#[derive(Debug, Clone, PartialEq)]
15pub enum SortOrder {
16    Default,         // RSS feed order (as received)
17    DateNewest,      // Newest first
18    DateOldest,      // Oldest first
19}
20
21impl SortOrder {
22    pub fn next(&self) -> Self {
23        match self {
24            SortOrder::Default => SortOrder::DateNewest,
25            SortOrder::DateNewest => SortOrder::DateOldest,
26            SortOrder::DateOldest => SortOrder::Default,
27        }
28    }
29
30    pub fn name(&self) -> &str {
31        match self {
32            SortOrder::Default => "Default",
33            SortOrder::DateNewest => "Newest First",
34            SortOrder::DateOldest => "Oldest First",
35        }
36    }
37}
38
39#[derive(Debug, Clone, PartialEq)]
40pub enum ImageProtocol {
41    Auto,       // Automatically detect best protocol
42    Halfblocks, // Unicode half blocks (widely compatible)
43    Sixel,      // High quality Sixel graphics
44    Kitty,      // Kitty graphics protocol (high quality, modern terminals)
45}
46
47impl ImageProtocol {
48    pub fn next(&self) -> Self {
49        match self {
50            ImageProtocol::Auto => ImageProtocol::Halfblocks,
51            ImageProtocol::Halfblocks => ImageProtocol::Sixel,
52            ImageProtocol::Sixel => ImageProtocol::Kitty,
53            ImageProtocol::Kitty => ImageProtocol::Auto,
54        }
55    }
56
57    pub fn name(&self) -> &str {
58        match self {
59            ImageProtocol::Auto => "Auto",
60            ImageProtocol::Halfblocks => "Halfblocks",
61            ImageProtocol::Sixel => "Sixel",
62            ImageProtocol::Kitty => "Kitty",
63        }
64    }
65}
66
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68pub struct NewsStory {
69    pub title: String,
70    pub description: String,
71    pub link: String,
72    pub pub_date: String,
73    pub category: String,
74    pub image_url: Option<String>,
75}
76
77pub struct App {
78    pub stories: Vec<NewsStory>,
79    pub ticker_stories: Vec<NewsStory>,  // Always contains Top Stories for ticker
80    pub selected: usize,
81    pub should_quit: bool,
82    pub is_loading: bool,
83    pub is_refreshing: bool,               // Track if currently refreshing data
84    pub is_offline: bool,                  // Track offline mode
85    pub error_message: Option<String>,
86    pub ticker_index: usize,
87    pub ticker_counter: u32,
88    pub mode: AppMode,
89    pub current_feed: Feed,
90    pub feed_menu_selected: usize,
91    pub show_preview: bool,
92    pub humanize_dates: bool,
93    pub image_protocol: ImageProtocol,
94    pub theme: Theme,                      // Current theme
95    pub show_full_article: bool,           // Toggle between preview and full article view
96    pub article_scroll_offset: usize,      // Scroll position in article view
97    pub is_fetching_article: bool,         // Loading state for article fetching
98    pub sort_order: SortOrder,             // Current sort order
99    pub last_refresh_time: Instant,        // Track last refresh for auto-refresh
100    article_cache: HashMap<String, String>, // Cache fetched articles by URL
101    last_opened_index: Option<usize>,      // Track last opened article index to prevent repeated opens
102    last_open_time: Option<Instant>,       // Track last open time for cooldown
103    last_selection_change_time: Instant,   // Track when selection last changed
104    scroll_count: u32,                     // Count rapid scroll events (for storm detection)
105    last_scroll_time: Instant,             // Track last scroll event time
106    scroll_pause_until: Option<Instant>,   // When to resume scrolling after storm
107}
108
109impl App {
110    pub fn new(theme: Theme) -> Self {
111        Self {
112            stories: Vec::new(),
113            ticker_stories: Vec::new(),
114            selected: 0,
115            should_quit: false,
116            is_loading: true,
117            is_refreshing: false,                  // Not refreshing initially
118            is_offline: false,                     // Start in online mode
119            error_message: None,
120            ticker_index: 0,
121            ticker_counter: 0,
122            mode: AppMode::Normal,
123            current_feed: get_default_feed(),
124            feed_menu_selected: 0,
125            show_preview: false,
126            humanize_dates: true,  // Default to humanized dates
127            image_protocol: ImageProtocol::Auto,  // Auto-detect best protocol
128            theme,                                 // Theme from config
129            show_full_article: false,              // Start in preview mode
130            article_scroll_offset: 0,              // Start at top of article
131            is_fetching_article: false,            // Not fetching initially
132            sort_order: SortOrder::Default,        // Default RSS order
133            last_refresh_time: Instant::now(),     // Initialize to now
134            article_cache: HashMap::new(),         // Empty cache
135            last_opened_index: None,               // No article opened yet
136            last_open_time: None,                  // No article opened yet
137            last_selection_change_time: Instant::now(),  // Initialize to now
138            scroll_count: 0,                       // No scrolls yet
139            last_scroll_time: Instant::now(),      // Initialize to now
140            scroll_pause_until: None,              // Not paused
141        }
142    }
143
144    // Scroll storm detection: prevents catastrophic event buffering in Ghostty
145    // Returns true if scrolling should be allowed, false if paused
146    fn check_scroll_storm(&mut self) -> bool {
147        // Only enable storm detection when preview is OPEN
148        // (When preview is closed, scroll keys work normally without blocking)
149        if !self.show_preview {
150            return true; // Preview closed, allow all scrolling
151        }
152
153        // Check if currently paused
154        if let Some(pause_until) = self.scroll_pause_until {
155            if Instant::now() < pause_until {
156                return false; // Still paused, ignore scroll
157            } else {
158                // Pause expired, resume scrolling
159                self.scroll_pause_until = None;
160                self.scroll_count = 0;
161            }
162        }
163
164        // Check if this is part of a scroll storm
165        let now = Instant::now();
166        let time_since_last_scroll = now.duration_since(self.last_scroll_time).as_secs_f32();
167
168        if time_since_last_scroll > 0.5 {
169            // Been a while since last scroll, reset counter
170            self.scroll_count = 0;
171        }
172
173        // Increment scroll count
174        self.scroll_count += 1;
175        self.last_scroll_time = now;
176
177        // Check if storm detected (more than 10 scrolls in 0.5 seconds)
178        if self.scroll_count > 10 && time_since_last_scroll < 0.5 {
179            // SCROLL STORM DETECTED! Pause scrolling for 1 second
180            self.scroll_pause_until = Some(now + std::time::Duration::from_secs(1));
181            self.scroll_count = 0;
182            return false; // Ignore this scroll
183        }
184
185        true // Allow scroll
186    }
187
188    pub fn next(&mut self) {
189        // Check for scroll storm - pause scrolling if detected
190        if !self.check_scroll_storm() {
191            return; // Scroll storm detected, ignoring this scroll event
192        }
193
194        if !self.stories.is_empty() {
195            self.selected = (self.selected + 1).min(self.stories.len() - 1);
196            // Clear last opened index when selection changes - allows opening new article
197            self.last_opened_index = None;
198            // Record when selection changed - prevents immediate opens
199            self.last_selection_change_time = Instant::now();
200        }
201    }
202
203    pub fn previous(&mut self) {
204        // Check for scroll storm - pause scrolling if detected
205        if !self.check_scroll_storm() {
206            return; // Scroll storm detected, ignoring this scroll event
207        }
208
209        if self.selected > 0 {
210            self.selected -= 1;
211            // Clear last opened index when selection changes - allows opening new article
212            self.last_opened_index = None;
213            // Record when selection changed - prevents immediate opens
214            self.last_selection_change_time = Instant::now();
215        }
216    }
217
218    pub fn scroll_to_bottom(&mut self) {
219        // Check for scroll storm - pause scrolling if detected
220        if !self.check_scroll_storm() {
221            return; // Scroll storm detected, ignoring this scroll event
222        }
223
224        if !self.stories.is_empty() {
225            self.selected = self.stories.len() - 1;
226            // Clear last opened index when selection changes - allows opening new article
227            self.last_opened_index = None;
228            // Record when selection changed - prevents immediate opens
229            self.last_selection_change_time = Instant::now();
230        }
231    }
232
233    pub fn scroll_to_top(&mut self) {
234        // Check for scroll storm - pause scrolling if detected
235        if !self.check_scroll_storm() {
236            return; // Scroll storm detected, ignoring this scroll event
237        }
238
239        self.selected = 0;
240        // Clear last opened index when selection changes - allows opening new article
241        self.last_opened_index = None;
242        // Record when selection changed - prevents immediate opens
243        self.last_selection_change_time = Instant::now();
244    }
245
246    pub fn open_selected(&mut self) -> anyhow::Result<()> {
247        // AGGRESSIVE PROTECTION against Ghostty event buffering catastrophe:
248
249        // 1. SELECTION STABILITY: Prevent opening if selection changed recently
250        //    (Requires 3 seconds on same article before opening)
251        if self.last_selection_change_time.elapsed().as_secs_f32() < 3.0 {
252            return Ok(()); // Selection changed too recently, ignore
253        }
254
255        // 2. TIME-BASED: Prevent opening ANY article within 5 seconds of last open
256        //    (Prevents rapid-fire opens even when scrolling to different articles)
257        if let Some(last_time) = self.last_open_time {
258            if last_time.elapsed().as_secs_f32() < 5.0 {
259                return Ok(()); // Still in cooldown, ignore
260            }
261        }
262
263        // 3. INDEX-BASED: Prevent opening same article multiple times
264        //    (Additional protection against repeated opens)
265        if let Some(last_idx) = self.last_opened_index {
266            if last_idx == self.selected {
267                return Ok(()); // Same article, ignore
268            }
269        }
270
271        // All checks passed, open the article
272        if let Some(story) = self.stories.get(self.selected) {
273            webbrowser::open(&story.link)?;
274            self.last_opened_index = Some(self.selected);
275            self.last_open_time = Some(Instant::now());
276        }
277        Ok(())
278    }
279
280    pub fn open_selected_new_tab(&mut self) -> anyhow::Result<()> {
281        // Most browsers will open in a new tab by default
282        self.open_selected()
283    }
284
285    pub fn quit(&mut self) {
286        self.should_quit = true;
287    }
288
289    pub fn update_stories(&mut self, stories: Vec<NewsStory>) {
290        self.stories = stories;
291        self.is_loading = false;
292        self.is_refreshing = false;
293        // Check selection bounds before sorting
294        if self.selected >= self.stories.len() && !self.stories.is_empty() {
295            self.selected = self.stories.len() - 1;
296        }
297        // Re-apply current sort order after updating stories
298        // Note: apply_sort() resets selection to 0, which is intentional for sorted views
299        if self.sort_order != crate::app::SortOrder::Default {
300            self.apply_sort();
301        }
302    }
303
304    pub fn update_ticker_stories(&mut self, stories: Vec<NewsStory>) {
305        self.ticker_stories = stories;
306        self.is_refreshing = false;
307    }
308
309    pub fn set_error(&mut self, error: String) {
310        self.error_message = Some(error);
311        self.is_loading = false;
312        self.is_refreshing = false;
313    }
314
315    pub fn clear_error(&mut self) {
316        self.error_message = None;
317    }
318
319    pub fn tick(&mut self) -> bool {
320        self.ticker_counter += 1;
321        // Rotate ticker every 100 ticks (approximately 10 seconds at 100ms polling)
322        if self.ticker_counter >= 100 {
323            self.ticker_counter = 0;
324            self.rotate_ticker();
325        }
326        // Return true every 10 ticks (approximately 1 second) to trigger clock update
327        self.ticker_counter % 10 == 0
328    }
329
330    fn rotate_ticker(&mut self) {
331        if self.ticker_stories.is_empty() {
332            self.ticker_index = 0;
333            return;
334        }
335
336        let max_ticker_items = 5.min(self.ticker_stories.len());
337        self.ticker_index = (self.ticker_index + 1) % max_ticker_items;
338    }
339
340    pub fn toggle_feed_menu(&mut self) {
341        self.mode = if self.mode == AppMode::FeedMenu {
342            AppMode::Normal
343        } else {
344            AppMode::FeedMenu
345        };
346    }
347
348    pub fn toggle_preview(&mut self) {
349        self.show_preview = !self.show_preview;
350    }
351
352    pub fn toggle_date_format(&mut self) {
353        self.humanize_dates = !self.humanize_dates;
354    }
355
356    pub fn cycle_image_protocol(&mut self) {
357        self.image_protocol = self.image_protocol.next();
358    }
359
360    pub fn feed_menu_next(&mut self, feed_count: usize) {
361        if feed_count > 0 {
362            self.feed_menu_selected = (self.feed_menu_selected + 1).min(feed_count - 1);
363        }
364    }
365
366    pub fn feed_menu_previous(&mut self) {
367        if self.feed_menu_selected > 0 {
368            self.feed_menu_selected -= 1;
369        }
370    }
371
372    pub fn select_feed(&mut self, feed: Feed) {
373        self.current_feed = feed;
374        self.mode = AppMode::Normal;
375        self.is_loading = true;
376        self.selected = 0;
377    }
378
379    // Article viewing methods
380    pub fn fetch_and_show_article(&mut self) {
381        if let Some(story) = self.stories.get(self.selected) {
382            let url = &story.link;
383
384            // Check cache first
385            if !self.article_cache.contains_key(url) {
386                // Not in cache, fetch it
387                self.is_fetching_article = true;
388
389                use crate::article_fetcher::fetch_article_content;
390                match fetch_article_content(url) {
391                    Ok(content) => {
392                        self.article_cache.insert(url.clone(), content);
393                        self.is_fetching_article = false;
394                        self.show_full_article = true;
395                        self.article_scroll_offset = 0;
396                    }
397                    Err(e) => {
398                        self.is_fetching_article = false;
399                        self.set_error(format!("Failed to fetch article: {}", e));
400                    }
401                }
402            } else {
403                // Already cached, show it
404                self.show_full_article = true;
405                self.article_scroll_offset = 0;
406            }
407        }
408    }
409
410    pub fn toggle_article_view(&mut self) {
411        if self.show_full_article {
412            // Return to preview
413            self.show_full_article = false;
414            self.article_scroll_offset = 0;
415        }
416    }
417
418    pub fn scroll_article_up(&mut self) {
419        if self.article_scroll_offset > 0 {
420            self.article_scroll_offset = self.article_scroll_offset.saturating_sub(1);
421        }
422    }
423
424    pub fn scroll_article_down(&mut self) {
425        // Scroll down (limit will be checked during rendering)
426        self.article_scroll_offset += 1;
427    }
428
429    pub fn get_current_article_text(&self) -> Option<&String> {
430        if let Some(story) = self.stories.get(self.selected) {
431            self.article_cache.get(&story.link)
432        } else {
433            None
434        }
435    }
436
437    pub fn toggle_help_menu(&mut self) {
438        self.mode = if self.mode == AppMode::Help {
439            AppMode::Normal
440        } else {
441            AppMode::Help
442        };
443    }
444
445    pub fn cycle_theme(&mut self) {
446        self.theme = match self.theme.name.as_str() {
447            "Light" => Theme::dark(),
448            "Dark" => Theme::light(),
449            _ => Theme::light(),
450        };
451    }
452
453    pub fn cycle_sort_order(&mut self) {
454        self.sort_order = self.sort_order.next();
455        self.apply_sort();
456    }
457
458    fn apply_sort(&mut self) {
459        match self.sort_order {
460            SortOrder::Default => {
461                // Keep original RSS order - do nothing or reload
462            }
463            SortOrder::DateNewest => {
464                // Sort by date, newest first
465                self.stories.sort_by(|a, b| b.pub_date.cmp(&a.pub_date));
466            }
467            SortOrder::DateOldest => {
468                // Sort by date, oldest first
469                self.stories.sort_by(|a, b| a.pub_date.cmp(&b.pub_date));
470            }
471        }
472        // Reset selection to top after sorting
473        self.selected = 0;
474    }
475
476    pub fn check_auto_refresh(&self) -> bool {
477        // Auto-refresh every 5 minutes (300 seconds)
478        const AUTO_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
479        self.last_refresh_time.elapsed() >= AUTO_REFRESH_INTERVAL
480    }
481
482    pub fn mark_refreshed(&mut self) {
483        self.last_refresh_time = Instant::now();
484    }
485
486    // Jump to the current ticker article
487    // Returns true if feed needs to change (trigger FeedChanged action)
488    pub fn jump_to_ticker_article(&mut self) -> bool {
489        // Check if ticker has any stories
490        if self.ticker_stories.is_empty() || self.ticker_index >= self.ticker_stories.len() {
491            return false;
492        }
493
494        // Get the current ticker story
495        let ticker_story = &self.ticker_stories[self.ticker_index];
496        let ticker_url = &ticker_story.link;
497
498        // Check if we're already on Top Stories feed
499        let top_stories_feed = crate::feeds::get_default_feed();
500        let need_feed_change = self.current_feed.url != top_stories_feed.url;
501
502        if need_feed_change {
503            // Switch to Top Stories feed
504            self.current_feed = top_stories_feed;
505            self.is_loading = true;
506            self.selected = 0;
507            return true; // Signal that feed needs to be fetched
508        } else {
509            // Already on Top Stories, just find and select the ticker article
510            if let Some(index) = self.stories.iter().position(|s| s.link == *ticker_url) {
511                self.selected = index;
512                // Clear last opened to allow opening this article
513                self.last_opened_index = None;
514                self.last_selection_change_time = Instant::now();
515            } else {
516                // Story not found, select first story
517                self.selected = 0;
518            }
519            return false;
520        }
521    }
522}