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}