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, DateNewest, DateOldest, }
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, Halfblocks, Sixel, Kitty, }
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>, pub selected: usize,
81 pub should_quit: bool,
82 pub is_loading: bool,
83 pub is_refreshing: bool, pub is_offline: bool, 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, pub show_full_article: bool, pub article_scroll_offset: usize, pub is_fetching_article: bool, pub sort_order: SortOrder, pub last_refresh_time: Instant, article_cache: HashMap<String, String>, last_opened_index: Option<usize>, last_open_time: Option<Instant>, last_selection_change_time: Instant, scroll_count: u32, last_scroll_time: Instant, scroll_pause_until: Option<Instant>, }
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, is_offline: false, 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, image_protocol: ImageProtocol::Auto, theme, show_full_article: false, article_scroll_offset: 0, is_fetching_article: false, sort_order: SortOrder::Default, last_refresh_time: Instant::now(), article_cache: HashMap::new(), last_opened_index: None, last_open_time: None, last_selection_change_time: Instant::now(), scroll_count: 0, last_scroll_time: Instant::now(), scroll_pause_until: None, }
142 }
143
144 fn check_scroll_storm(&mut self) -> bool {
147 if !self.show_preview {
150 return true; }
152
153 if let Some(pause_until) = self.scroll_pause_until {
155 if Instant::now() < pause_until {
156 return false; } else {
158 self.scroll_pause_until = None;
160 self.scroll_count = 0;
161 }
162 }
163
164 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 self.scroll_count = 0;
171 }
172
173 self.scroll_count += 1;
175 self.last_scroll_time = now;
176
177 if self.scroll_count > 10 && time_since_last_scroll < 0.5 {
179 self.scroll_pause_until = Some(now + std::time::Duration::from_secs(1));
181 self.scroll_count = 0;
182 return false; }
184
185 true }
187
188 pub fn next(&mut self) {
189 if !self.check_scroll_storm() {
191 return; }
193
194 if !self.stories.is_empty() {
195 self.selected = (self.selected + 1).min(self.stories.len() - 1);
196 self.last_opened_index = None;
198 self.last_selection_change_time = Instant::now();
200 }
201 }
202
203 pub fn previous(&mut self) {
204 if !self.check_scroll_storm() {
206 return; }
208
209 if self.selected > 0 {
210 self.selected -= 1;
211 self.last_opened_index = None;
213 self.last_selection_change_time = Instant::now();
215 }
216 }
217
218 pub fn scroll_to_bottom(&mut self) {
219 if !self.check_scroll_storm() {
221 return; }
223
224 if !self.stories.is_empty() {
225 self.selected = self.stories.len() - 1;
226 self.last_opened_index = None;
228 self.last_selection_change_time = Instant::now();
230 }
231 }
232
233 pub fn scroll_to_top(&mut self) {
234 if !self.check_scroll_storm() {
236 return; }
238
239 self.selected = 0;
240 self.last_opened_index = None;
242 self.last_selection_change_time = Instant::now();
244 }
245
246 pub fn open_selected(&mut self) -> anyhow::Result<()> {
247 if self.last_selection_change_time.elapsed().as_secs_f32() < 3.0 {
252 return Ok(()); }
254
255 if let Some(last_time) = self.last_open_time {
258 if last_time.elapsed().as_secs_f32() < 5.0 {
259 return Ok(()); }
261 }
262
263 if let Some(last_idx) = self.last_opened_index {
266 if last_idx == self.selected {
267 return Ok(()); }
269 }
270
271 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 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 if self.selected >= self.stories.len() && !self.stories.is_empty() {
295 self.selected = self.stories.len() - 1;
296 }
297 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 if self.ticker_counter >= 100 {
323 self.ticker_counter = 0;
324 self.rotate_ticker();
325 }
326 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 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 if !self.article_cache.contains_key(url) {
386 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 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 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 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 }
463 SortOrder::DateNewest => {
464 self.stories.sort_by(|a, b| b.pub_date.cmp(&a.pub_date));
466 }
467 SortOrder::DateOldest => {
468 self.stories.sort_by(|a, b| a.pub_date.cmp(&b.pub_date));
470 }
471 }
472 self.selected = 0;
474 }
475
476 pub fn check_auto_refresh(&self) -> bool {
477 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 pub fn jump_to_ticker_article(&mut self) -> bool {
489 if self.ticker_stories.is_empty() || self.ticker_index >= self.ticker_stories.len() {
491 return false;
492 }
493
494 let ticker_story = &self.ticker_stories[self.ticker_index];
496 let ticker_url = &ticker_story.link;
497
498 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 self.current_feed = top_stories_feed;
505 self.is_loading = true;
506 self.selected = 0;
507 return true; } else {
509 if let Some(index) = self.stories.iter().position(|s| s.link == *ticker_url) {
511 self.selected = index;
512 self.last_opened_index = None;
514 self.last_selection_change_time = Instant::now();
515 } else {
516 self.selected = 0;
518 }
519 return false;
520 }
521 }
522}