Skip to main content

wiki_tui/components/
page_viewer.rs

1use std::{collections::HashMap, sync::Arc};
2
3use ratatui::{
4    prelude::{Alignment, Rect},
5    style::Style,
6};
7use tokio::sync::mpsc::UnboundedSender;
8use tracing::{debug, error};
9use uuid::Uuid;
10
11use crate::{
12    action::{Action, ActionResult, PageViewerAction},
13    config::{Config, Theme},
14    terminal::Frame,
15    ui::centered_rect,
16};
17
18use super::{page::PageComponent, page_language_popup::PageLanguageSelectionComponent, Component};
19
20use wiki_api::{
21    languages::Language,
22    page::{Page, Property},
23};
24
25/// Can display multiple pages and supports selecting between them
26/// Responsible for fetching the pages and managing them (NOT rendering)
27#[derive(Default)]
28pub struct PageViewer {
29    page: Vec<PageComponent>,
30    page_n: usize,
31    page_cache: HashMap<Uuid, PageComponent>,
32    /// Maps (title, language_code) -> UUID for quick cache lookups
33    page_identifier_index: HashMap<(String, String), Uuid>,
34
35    is_processing: bool,
36    changing_page_language_popup: Option<PageLanguageSelectionComponent>,
37
38    config: Arc<Config>,
39    theme: Arc<Theme>,
40
41    action_tx: Option<UnboundedSender<Action>>,
42}
43
44impl PageViewer {
45    fn cache_path() -> std::path::PathBuf {
46        let mut path = directories::ProjectDirs::from("com", "github", "wiki-tui")
47            .unwrap()
48            .cache_dir()
49            .to_path_buf();
50        path.push("page_cache.json");
51        path
52    }
53
54    fn load_cache(&mut self) {
55        let path = Self::cache_path();
56        if !path.exists() {
57            debug!("no cache file found at {:?}", path);
58            return;
59        }
60
61        let file = match std::fs::File::open(&path) {
62            Ok(file) => file,
63            Err(e) => {
64                error!("failed to open cache file at {:?}: {}", path, e);
65                return;
66            }
67        };
68
69        let reader = std::io::BufReader::new(file);
70        match serde_json::from_reader(reader) {
71            Ok(cache) => {
72                debug!("successfully loaded cache from {:?}", path);
73                self.page_cache = cache;
74                self.rebuild_identifier_index();
75            }
76            Err(e) => {
77                error!("failed to deserialize cache from {:?}: {}", path, e);
78            }
79        };
80    }
81
82    fn rebuild_identifier_index(&mut self) {
83        self.page_identifier_index.clear();
84        for (uuid, page_component) in &self.page_cache {
85            let key = (
86                page_component.page.title.clone(),
87                page_component.page.language.code().to_string(),
88            );
89            self.page_identifier_index.insert(key, *uuid);
90        }
91        debug!(
92            "rebuilt identifier index with {} entries",
93            self.page_identifier_index.len()
94        );
95    }
96
97    fn save_cache(&self) {
98        let path = Self::cache_path();
99        let file = match std::fs::File::create(&path) {
100            Ok(file) => file,
101            Err(e) => {
102                error!("failed to create cache file at {:?}: {}", path, e);
103                return;
104            }
105        };
106
107        let writer = std::io::BufWriter::new(file);
108        match serde_json::to_writer(writer, &self.page_cache) {
109            Ok(_) => debug!("successfully saved cache to {:?}", path),
110            Err(e) => error!("failed to serialize and save cache to {:?}: {}", path, e),
111        }
112    }
113
114    /// Syncs all currently active pages back to the page_cache, then saves to disk
115    pub fn sync_and_save_cache(&mut self) {
116        debug!("syncing {} active pages to cache", self.page.len());
117        for page_component in &self.page {
118            let key = (
119                page_component.page.title.clone(),
120                page_component.page.language.code().to_string(),
121            );
122            self.page_cache
123                .insert(page_component.page.uuid, page_component.clone());
124            self.page_identifier_index
125                .insert(key, page_component.page.uuid);
126        }
127        self.save_cache();
128    }
129
130    /// Check if a page is already cached by its identifier
131    pub fn get_cached_page(&self, title: &str, language: Language) -> Option<Page> {
132        let key = (title.to_string(), language.code().to_string());
133        debug!(
134            "cache lookup for: title='{}', language='{}'",
135            title,
136            language.code()
137        );
138        let uuid = self.page_identifier_index.get(&key)?;
139        debug!("found uuid in index: {}", uuid);
140        let page_component = self.page_cache.get(uuid)?;
141        debug!("found page component in cache");
142        Some(page_component.page.clone())
143    }
144
145    fn current_page_mut(&mut self) -> Option<&mut PageComponent> {
146        self.page.get_mut(self.page_n)
147    }
148
149    pub fn current_page(&self) -> Option<&PageComponent> {
150        self.page.get(self.page_n)
151    }
152
153    fn display_page(&mut self, page: Page) {
154        self.page_n = self.page.len();
155        debug!(
156            "display_page called for '{}' with uuid {}",
157            page.title, page.uuid
158        );
159
160        // First try to find by UUID (exact match)
161        if let Some(mut cached_page) = self.page_cache.get(&page.uuid).cloned() {
162            debug!("found page in cache by uuid, using cached version");
163            cached_page.rebuild(self.config.clone(), self.theme.clone());
164            self.page.push(cached_page);
165        } else {
166            // UUID not found, check if we have this page by (title, language)
167            let key = (page.title.clone(), page.language.code().to_string());
168
169            if let Some(&existing_uuid) = self.page_identifier_index.get(&key) {
170                // We have this page cached, but with a different UUID
171                debug!(
172                    "found existing page in index with different uuid {}, updating uuid to {}",
173                    existing_uuid, page.uuid
174                );
175
176                // Remove the old UUID entry and add with new UUID
177                if let Some(mut existing_page) = self.page_cache.remove(&existing_uuid) {
178                    // Update the page data with the new fetch (in case content changed)
179                    existing_page.page = page.clone();
180                    existing_page.rebuild(self.config.clone(), self.theme.clone());
181
182                    // Store with new UUID and update index
183                    self.page_cache.insert(page.uuid, existing_page.clone());
184                    self.page_identifier_index.insert(key, page.uuid);
185                    self.page.push(existing_page);
186                } else {
187                    // Index pointed to non-existent UUID, treat as new page
188                    debug!("index pointed to non-existent uuid, creating new page");
189                    let new_page =
190                        PageComponent::new(page.clone(), self.config.clone(), self.theme.clone());
191                    self.page_cache.insert(page.uuid, new_page.clone());
192                    self.page_identifier_index.insert(key, page.uuid);
193                    self.page.push(new_page);
194                }
195            } else {
196                // Truly new page, not in index at all
197                debug!("page not in cache or index, creating new PageComponent");
198                let new_page =
199                    PageComponent::new(page.clone(), self.config.clone(), self.theme.clone());
200                debug!("adding page to cache and index with key: {:?}", key);
201                self.page_cache.insert(new_page.page.uuid, new_page.clone());
202                self.page_identifier_index.insert(key, new_page.page.uuid);
203                self.page.push(new_page);
204            }
205            self.save_cache();
206        }
207
208        if self.changing_page_language_popup.is_some() {
209            self.changing_page_language_popup = None;
210        }
211
212        // always disable the processing screen when displaying a page
213        self.is_processing = false;
214    }
215
216    fn pop(&mut self) {
217        self.page.pop();
218        self.page_n = self.page_n.saturating_sub(1);
219    }
220
221    pub fn get_page_language_selection_popup(&self) -> PageLanguageSelectionComponent {
222        let language_links = self
223            .current_page()
224            .and_then(|x| x.page.language_links.to_owned())
225            .unwrap_or_default();
226        PageLanguageSelectionComponent::new(language_links, self.config.clone(), self.theme.clone())
227    }
228}
229
230impl Component for PageViewer {
231    fn init(
232        &mut self,
233        action_tx: UnboundedSender<Action>,
234        config: Arc<Config>,
235        theme: Arc<Theme>,
236    ) -> anyhow::Result<()> {
237        self.action_tx = Some(action_tx);
238        self.config = config;
239        self.theme = theme;
240        self.load_cache();
241        Ok(())
242    }
243    fn handle_key_events(&mut self, key: crossterm::event::KeyEvent) -> ActionResult {
244        if self
245            .config
246            .bindings
247            .page
248            .toggle_page_language_selection
249            .matches_event(key)
250        {
251            return Action::ShowPageLanguageSelection.into();
252        }
253
254        if self.config.bindings.page.pop_page.matches_event(key) {
255            return Action::PageViewer(PageViewerAction::PopPage).into();
256        }
257
258        if let Some(page) = self.current_page_mut() {
259            return page.handle_key_events(key);
260        }
261
262        ActionResult::Ignored
263    }
264
265    fn update(&mut self, action: Action) -> ActionResult {
266        match action {
267            Action::TryLoadPage(title, language, endpoint) => {
268                if let Some(cached_page) = self.get_cached_page(&title, language) {
269                    debug!("cache hit for page '{}' - loading instantly", title);
270                    self.action_tx
271                        .as_ref()
272                        .unwrap()
273                        .send(Action::SwitchContextPage)
274                        .unwrap();
275                    self.display_page(cached_page);
276                    return ActionResult::consumed();
277                } else {
278                    debug!("cache miss for page '{}' - fetching from API", title);
279                    // Cache miss - fetch from API
280                    let page_request = wiki_api::page::Page::builder()
281                        .page(title.clone())
282                        .properties(vec![
283                            Property::Text,
284                            Property::Sections,
285                            Property::LangLinks,
286                        ])
287                        .endpoint(endpoint)
288                        .language(language)
289                        .redirects(self.config.api.page_redirects);
290
291                    let tx = self.action_tx.clone().unwrap();
292                    tokio::spawn(async move {
293                        tx.send(Action::SwitchContextPage).unwrap();
294                        tx.send(Action::EnterProcessing).unwrap();
295
296                        match page_request.fetch().await {
297                            Ok(page) => tx
298                                .send(Action::PageViewer(
299                                    crate::action::PageViewerAction::DisplayPage(page),
300                                ))
301                                .unwrap(),
302                            Err(error) => {
303                                let error_msg =
304                                    format!("Unable to fetch the page '{}': {}", title, error);
305                                tracing::error!("{}", error_msg);
306                                tx.send(Action::PageViewer(
307                                    crate::action::PageViewerAction::ExitLoading,
308                                ))
309                                .unwrap();
310                                tx.send(Action::PopupError(error_msg)).unwrap();
311                            }
312                        };
313
314                        tx.send(Action::EnterNormal).unwrap();
315                    });
316                    return ActionResult::consumed();
317                }
318            }
319            Action::PageViewer(page_viewer_action) => match page_viewer_action {
320                PageViewerAction::DisplayPage(page) => self.display_page(page),
321                PageViewerAction::PopPage => self.pop(),
322                PageViewerAction::ExitLoading => self.is_processing = false,
323                PageViewerAction::SaveCache => self.sync_and_save_cache(),
324            },
325            Action::EnterProcessing => self.is_processing = true,
326            Action::EnterNormal => self.is_processing = false,
327            _ => {
328                if let Some(page) = self.current_page_mut() {
329                    return page.update(action);
330                }
331                return ActionResult::Ignored;
332            }
333        }
334        ActionResult::consumed()
335    }
336
337    fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
338        if self.is_processing {
339            f.render_widget(
340                self.theme.default_block().border_style(
341                    Style::default()
342                        .fg(self.theme.border_highlight_fg)
343                        .bg(self.theme.border_highlight_bg),
344                ),
345                area,
346            );
347            f.render_widget(
348                self.theme
349                    .default_paragraph("Processing")
350                    .alignment(Alignment::Center),
351                centered_rect(area, 100, 50),
352            );
353            return;
354        }
355
356        if self.current_page().is_none() {
357            f.render_widget(
358                self.theme
359                    .default_paragraph("No page opened")
360                    .alignment(Alignment::Center),
361                centered_rect(area, 100, 50),
362            );
363            return;
364        }
365
366        if let Some(page) = self.current_page_mut() {
367            page.render(f, area);
368        }
369    }
370}