Skip to main content

feed/tui/
app.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use crate::article::Article;
5use crate::article_store::{ArticleStore, FilterParams};
6use ratatui::layout::Rect;
7use ratatui::widgets::ListState;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub enum Screen {
11    ArticleList,
12    ArticleView,
13}
14
15#[derive(Debug, Clone, Default)]
16pub struct LayoutAreas {
17    pub main_area: Rect,
18    pub status_bar: Rect,
19}
20
21pub struct App {
22    pub screen: Screen,
23    pub store: ArticleStore,
24    pub filter_params: FilterParams,
25    pub filtered_indices: Vec<usize>,
26    pub selected: usize,
27    pub scroll_offset: usize,
28    pub article_content: Option<String>,
29    pub article_title: Option<String>,
30    pub article_url: Option<String>,
31    pub loading: bool,
32    pub should_quit: bool,
33    pub status_message: Option<String>,
34    pub layout_areas: LayoutAreas,
35    pub list_state: ListState,
36    pub last_refresh: Instant,
37    pub auto_refresh_interval: Option<Duration>,
38    pub content_cache: HashMap<String, String>,
39}
40
41impl App {
42    pub fn new(store: ArticleStore, filter_params: FilterParams) -> Self {
43        let filtered_indices = store.query(&filter_params);
44        let auto_refresh_secs = store.config().tui.auto_refresh_interval;
45        let auto_refresh_interval = if auto_refresh_secs > 0 {
46            Some(Duration::from_secs(auto_refresh_secs))
47        } else {
48            None
49        };
50        Self {
51            screen: Screen::ArticleList,
52            store,
53            filter_params,
54            filtered_indices,
55            selected: 0,
56            scroll_offset: 0,
57            article_content: None,
58            article_title: None,
59            article_url: None,
60            loading: false,
61            should_quit: false,
62            status_message: None,
63            layout_areas: LayoutAreas::default(),
64            list_state: ListState::default(),
65            last_refresh: Instant::now(),
66            auto_refresh_interval,
67            content_cache: HashMap::new(),
68        }
69    }
70
71    pub fn current_article(&self) -> Option<&Article> {
72        let &idx = self.filtered_indices.get(self.selected)?;
73        self.store.get(idx)
74    }
75
76    pub fn filtered_len(&self) -> usize {
77        self.filtered_indices.len()
78    }
79
80    pub fn move_down(&mut self) {
81        if !self.filtered_indices.is_empty() && self.selected < self.filtered_indices.len() - 1 {
82            self.selected += 1;
83        }
84    }
85
86    pub fn move_up(&mut self) {
87        if self.selected > 0 {
88            self.selected -= 1;
89        }
90    }
91
92    pub fn select(&mut self, index: usize) {
93        if self.filtered_indices.is_empty() {
94            return;
95        }
96        self.selected = index.min(self.filtered_indices.len() - 1);
97    }
98
99    fn article_line_count(&self) -> usize {
100        let content_lines = self
101            .article_content
102            .as_deref()
103            .map(|c| c.lines().count())
104            .unwrap_or(0);
105        3 + content_lines
106    }
107
108    pub fn clamp_scroll(&mut self, visible_height: usize) {
109        let max = self.article_line_count().saturating_sub(visible_height);
110        self.scroll_offset = self.scroll_offset.min(max);
111    }
112
113    pub fn scroll_down(&mut self, visible_height: usize) {
114        self.scroll_offset = self.scroll_offset.saturating_add(1);
115        self.clamp_scroll(visible_height);
116    }
117
118    pub fn scroll_up(&mut self) {
119        self.scroll_offset = self.scroll_offset.saturating_sub(1);
120    }
121
122    pub fn scroll_page_down(&mut self, page_height: usize, visible_height: usize) {
123        self.scroll_offset = self.scroll_offset.saturating_add(page_height);
124        self.clamp_scroll(visible_height);
125    }
126
127    pub fn scroll_page_up(&mut self, page_height: usize) {
128        self.scroll_offset = self.scroll_offset.saturating_sub(page_height);
129    }
130
131    pub fn selected_url(&self) -> Option<&str> {
132        self.current_article().map(|a| a.url.as_str())
133    }
134
135    pub fn show_article(&mut self, title: String, url: String, content: String) {
136        self.article_title = Some(title);
137        self.article_url = Some(url);
138        self.article_content = Some(content);
139        self.scroll_offset = 0;
140        self.screen = Screen::ArticleView;
141        self.loading = false;
142    }
143
144    pub fn has_prev_article(&self) -> bool {
145        self.selected > 0
146    }
147
148    pub fn has_next_article(&self) -> bool {
149        !self.filtered_indices.is_empty() && self.selected < self.filtered_indices.len() - 1
150    }
151
152    pub fn select_prev_article(&mut self) {
153        if self.has_prev_article() {
154            self.selected -= 1;
155            self.scroll_offset = 0;
156            self.loading = true;
157        }
158    }
159
160    pub fn select_next_article(&mut self) {
161        if self.has_next_article() {
162            self.selected += 1;
163            self.scroll_offset = 0;
164            self.loading = true;
165        }
166    }
167
168    pub fn close_article(&mut self) {
169        self.screen = Screen::ArticleList;
170        self.article_content = None;
171        self.article_title = None;
172        self.article_url = None;
173        self.scroll_offset = 0;
174    }
175
176    pub fn toggle_read_filter(&mut self) {
177        let selected_url = self.selected_url().map(|s| s.to_string());
178        self.filter_params.show_read = !self.filter_params.show_read;
179        self.filtered_indices = self.store.query(&self.filter_params);
180        if let Some(url) = selected_url {
181            if let Some(pos) = self
182                .filtered_indices
183                .iter()
184                .position(|&i| self.store.get(i).is_some_and(|a| a.url == url))
185            {
186                self.selected = pos;
187                return;
188            }
189        }
190        self.selected = self
191            .selected
192            .min(self.filtered_indices.len().saturating_sub(1));
193    }
194
195    pub fn is_showing_read(&self) -> bool {
196        self.filter_params.show_read
197    }
198
199    fn current_store_index(&self) -> Option<usize> {
200        self.filtered_indices.get(self.selected).copied()
201    }
202
203    pub fn mark_current_read(&mut self) {
204        if let Some(idx) = self.current_store_index() {
205            self.store.mark_read(idx);
206        }
207    }
208
209    pub fn toggle_current_read(&mut self) {
210        if let Some(idx) = self.current_store_index() {
211            self.store.toggle_read(idx);
212        }
213    }
214
215    pub fn should_auto_refresh(&self) -> bool {
216        matches!(self.auto_refresh_interval, Some(interval) if !self.loading && self.last_refresh.elapsed() >= interval)
217    }
218
219    pub fn reset_refresh_timer(&mut self) {
220        self.last_refresh = Instant::now();
221    }
222
223    pub fn rebuild_filtered_list(&mut self) {
224        let selected_url = self.selected_url().map(|s| s.to_string());
225        self.filtered_indices = self.store.query(&self.filter_params);
226
227        if let Some(url) = selected_url {
228            if let Some(pos) = self
229                .filtered_indices
230                .iter()
231                .position(|&i| self.store.get(i).is_some_and(|a| a.url == url))
232            {
233                self.selected = pos;
234                self.status_message = None;
235                return;
236            }
237        }
238
239        self.selected = self
240            .selected
241            .min(self.filtered_indices.len().saturating_sub(1));
242        self.status_message = None;
243    }
244
245    pub fn get_cached_content(&self, url: &str) -> Option<&String> {
246        self.content_cache.get(url)
247    }
248
249    pub fn cache_content(&mut self, url: String, content: String) {
250        self.content_cache.insert(url, content);
251    }
252}