1use std::{collections::HashMap, sync::Arc};
2
3use crossterm::event::{KeyCode, KeyEvent};
4use ratatui::{
5 layout::{Constraint, Direction, Flex, Layout},
6 prelude::{Margin, Rect},
7 style::{Color, Modifier, Style, Stylize},
8 text::{Line, Span},
9 widgets::{Block, List, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
10};
11use tracing::{debug, info, warn};
12use wiki_api::{
13 document::{Data, Node},
14 page::{Link, Page, Section},
15};
16
17use crate::{
18 action::{Action, ActionPacket, ActionResult, PageAction},
19 components::Component,
20 config::{Config, Theme, TocConfigPosition, TocConfigTitle, ZenModeComponents},
21 has_modifier,
22 renderer::{default_renderer::render_document, RenderedDocument},
23 terminal::Frame,
24 ui::padded_rect,
25};
26
27#[cfg(debug_assertions)]
28use crate::renderer::test_renderer::{render_nodes_raw, render_tree_data, render_tree_raw};
29
30#[derive(Default, Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31#[repr(u8)]
32pub enum Renderer {
33 #[default]
34 Default,
35
36 #[cfg(debug_assertions)]
37 TestRendererTreeData,
38 #[cfg(debug_assertions)]
39 TestRendererTreeRaw,
40 #[cfg(debug_assertions)]
41 TestRendererNodeRaw,
42}
43
44impl Renderer {
45 pub fn next(&self) -> Self {
46 match self {
47 #[cfg(not(debug_assertions))]
48 &Renderer::Default => Renderer::Default,
49
50 #[cfg(debug_assertions)]
51 &Renderer::Default => Renderer::TestRendererTreeData,
52 #[cfg(debug_assertions)]
53 &Renderer::TestRendererTreeData => Renderer::TestRendererTreeRaw,
54 #[cfg(debug_assertions)]
55 &Renderer::TestRendererTreeRaw => Renderer::TestRendererNodeRaw,
56 #[cfg(debug_assertions)]
57 &Renderer::TestRendererNodeRaw => Renderer::Default,
58 }
59 }
60}
61
62#[derive(Default, Clone)]
63struct PageContentsState {
64 list_state: ListState,
65 max_idx_section: u8,
66}
67
68macro_rules! rendered_page {
69 ($self: ident, $width: expr) => {
70 match $self.rendered_page($width) {
71 Some(page) => page,
72 None => {
73 $self.render_page($width);
74 $self.rendered_page($width).unwrap()
75 }
76 }
77 };
78}
79
80#[derive(serde::Serialize, serde::Deserialize, Clone)]
81pub struct PageComponent {
82 pub page: Page,
83 renderer: Renderer,
84 render_cache: HashMap<u16, RenderedDocument>,
85 viewport: Rect,
86 selected: (usize, usize),
87
88 #[serde(skip)]
89 config: Arc<Config>,
90 #[serde(skip)]
91 theme: Arc<Theme>,
92
93 is_contents: bool,
94 is_zen_mode: bool,
95 #[serde(skip)]
96 contents_state: PageContentsState,
97}
98
99impl PageComponent {
100 pub fn new(page: Page, config: Arc<Config>, theme: Arc<Theme>) -> Self {
101 let contents_state = PageContentsState {
102 list_state: ListState::default().with_selected(Some(0)),
103 max_idx_section: page.sections().map(|x| x.len() as u8).unwrap_or_default(),
104 };
105
106 Self {
107 page,
108 renderer: Renderer::default(),
109 render_cache: HashMap::new(),
110 viewport: Rect::default(),
111 selected: (0, 0),
112
113 is_contents: false,
114 is_zen_mode: config.page.default_zen,
115 contents_state,
116
117 config,
118 theme,
119 }
120 }
121
122 pub fn is_zen_mode(&self) -> bool {
123 self.is_zen_mode
124 }
125
126 pub fn rebuild(&mut self, config: Arc<Config>, theme: Arc<Theme>) {
127 self.config = config;
128 self.theme = theme;
129 self.contents_state = PageContentsState {
130 list_state: ListState::default().with_selected(Some(0)),
131 max_idx_section: self
132 .page
133 .sections()
134 .map(|x| x.len() as u8)
135 .unwrap_or_default(),
136 };
137 }
138
139 fn render_page(&mut self, width: u16) {
140 info!("rendering page '{}' at width {}", self.page.title, width);
141 let page = match self.renderer {
142 Renderer::Default => render_document(&self.page.content, width),
143 #[cfg(debug_assertions)]
144 Renderer::TestRendererTreeData => render_tree_data(&self.page.content),
145 #[cfg(debug_assertions)]
146 Renderer::TestRendererTreeRaw => render_tree_raw(&self.page.content),
147 #[cfg(debug_assertions)]
148 Renderer::TestRendererNodeRaw => render_nodes_raw(&self.page.content),
149 };
150
151 self.render_cache.insert(width, page);
152 info!(
153 "cached render for page '{}' at width {} (total cached widths: {})",
154 self.page.title,
155 width,
156 self.render_cache.len()
157 );
158 }
159
160 fn rendered_page(&self, width: u16) -> Option<&RenderedDocument> {
161 self.render_cache.get(&width)
162 }
163
164 fn render_contents(&mut self, f: &mut Frame<'_>, area: Rect) {
165 let title = match self.config.page.toc.title {
166 TocConfigTitle::Default => "Contents".to_string(),
167 TocConfigTitle::Article => self.page.title.to_string(),
168 TocConfigTitle::Custom(ref title) => title.to_string(),
169 };
170
171 let sections = self.page.sections.as_ref();
172 let mut block = self.theme.default_block().title(title);
173 if self.is_contents {
174 block = block.border_style(
175 Style::default()
176 .fg(self.theme.border_highlight_fg)
177 .bg(self.theme.border_highlight_bg),
178 );
179 }
180
181 if sections.is_none() {
182 f.render_widget(
183 self.theme
184 .default_paragraph("No Contents available")
185 .block(block),
186 area,
187 );
188 return;
189 }
190
191 let sections = sections.unwrap();
192 let list = List::new(sections.iter().map(|x| {
193 let mut item = self.config.page.toc.formatted_item(&x.number, &x.text);
194
195 if item
196 .char_indices()
197 .nth(block.inner(area).width as usize)
198 .is_some()
199 {
200 let idx = item
201 .char_indices()
202 .nth(block.inner(area).width.saturating_sub(3) as usize)
203 .map_or(0, |(idx, _)| idx);
204 let trunc = item.drain(0..idx).collect::<String>() + "...";
205 trunc.fg(self.theme.fg)
206 } else {
207 item.fg(self.theme.fg)
208 }
209 }))
210 .block(block)
211 .highlight_style(
212 Style::default()
213 .fg(self.theme.selected_fg)
214 .bg(self.theme.selected_bg)
215 .add_modifier(Modifier::ITALIC),
216 );
217 f.render_stateful_widget(list, area, &mut self.contents_state.list_state);
218 }
219
220 fn switch_renderer(&mut self, renderer: Renderer) {
221 self.renderer = renderer;
222
223 debug!("flushing '{}' cached renders", self.render_cache.len());
224 self.render_cache.clear();
225 self.selected = (0, 0);
226 }
227
228 fn select_header(&mut self, anchor: String) {
229 if &anchor == "Content_Top" {
231 info!("special case: jumping to top");
232 self.viewport.y = 0;
233 return;
234 }
235
236 let header_node = self
237 .page
238 .content
239 .nth(0)
240 .unwrap()
241 .descendants()
242 .filter(|node| {
243 if let Data::Header { id, .. } = node.data() {
244 id == &anchor
245 } else {
246 false
247 }
248 })
249 .last();
250
251 if header_node.is_none() {
252 warn!("no header with the anchor '{}' could be found", anchor);
253 return;
254 }
255
256 let header_node = header_node.unwrap();
257 self.scroll_to_node(header_node.index());
258 }
259
260 fn selected_header(&self) -> Option<&Section> {
261 let sections = self.page.sections()?;
262 let section_idx = self.contents_state.list_state.selected()?;
263 assert!(section_idx < self.contents_state.max_idx_section as usize);
264
265 Some(§ions[section_idx])
266 }
267
268 fn selected_y(&self) -> usize {
270 let page = match self.render_cache.get(&self.viewport.width) {
271 Some(page) => page,
272 None => return 0,
273 };
274
275 for (y, line) in page.lines.iter().enumerate() {
276 if line
277 .iter()
278 .any(|word| self.selected.0 <= word.index && self.selected.1 >= word.index)
279 {
280 return y;
281 }
282 }
283
284 0
285 }
286
287 fn select_node(&mut self, idx: usize) {
288 let node = match Node::new(&self.page.content, idx) {
289 Some(node) => node,
290 None => return,
291 };
292
293 let first_index = node.index();
294 let last_index = node.last_child().map(|x| x.index()).unwrap_or(first_index);
295
296 self.selected = (first_index, last_index);
297 }
298
299 fn selected_node(&self) -> Option<Node<'_>> {
300 self.page.content.nth(self.selected.0)
301 }
302
303 fn select_first(&mut self) {
304 if self.page.content.nth(0).is_none() {
305 return;
306 }
307
308 let selectable_node = self
309 .page
310 .content
311 .nth(0)
312 .unwrap()
313 .descendants()
314 .find(|node| matches!(node.data(), &Data::Link(_)));
315
316 if let Some(node) = selectable_node {
317 self.select_node(node.index());
318 self.check_and_update_scrolling();
319 }
320 }
321
322 fn select_last(&mut self) {
323 if self.page.content.nth(0).is_none() {
324 return;
325 }
326
327 let selectable_node = self
328 .page
329 .content
330 .nth(0)
331 .unwrap()
332 .descendants()
333 .filter(|node| matches!(node.data(), &Data::Link(_)) && node.index() > self.selected.1)
334 .last();
335
336 if let Some(node) = selectable_node {
337 self.select_node(node.index());
338 self.check_and_update_scrolling();
339 }
340 }
341
342 fn select_next(&mut self) {
343 if self.page.content.nth(0).is_none() {
344 return;
345 }
346
347 let selectable_node = self
348 .page
349 .content
350 .nth(0)
351 .unwrap()
352 .descendants()
353 .find(|node| matches!(node.data(), &Data::Link(_)) && self.selected.1 < node.index());
354
355 if let Some(node) = selectable_node {
356 self.select_node(node.index());
357 self.check_and_update_scrolling();
358 }
359 }
360
361 fn select_prev(&mut self) {
362 if self.page.content.nth(0).is_none() {
363 return;
364 }
365
366 let selectable_node = self
367 .page
368 .content
369 .nth(0)
370 .unwrap()
371 .descendants()
372 .filter(|node| matches!(node.data(), &Data::Link(_)) && node.index() < self.selected.0)
373 .last();
374
375 if let Some(node) = selectable_node {
376 self.select_node(node.index());
377 self.check_and_update_scrolling();
378 }
379 }
380
381 fn check_and_update_selection(&mut self) {
384 let page = rendered_page!(self, self.viewport.width);
385
386 let selected_y = self.selected_y() as u16;
387 let selected_node = match self.selected_node() {
388 Some(node) => node,
389 None => return,
390 };
391
392 if self.viewport.contains((0_u16, selected_y).into()) {
393 return;
394 }
395
396 if selected_y < self.viewport.top() {
397 let (_, idx) = page
398 .links
399 .iter()
400 .find(|(y, _)| self.viewport.contains((0, *y as u16).into()))
401 .map(|x| x.to_owned())
402 .unwrap_or((selected_y as usize, selected_node.index()));
403
404 self.select_node(idx);
405 return;
406 }
407
408 if selected_y > self.viewport.bottom() {
409 let (_, idx) = page
410 .links
411 .iter()
412 .rev()
413 .find(|(y, _)| self.viewport.contains((0, *y as u16).into()))
414 .map(|x| x.to_owned())
415 .unwrap_or((selected_y as usize, selected_node.index()));
416
417 self.select_node(idx)
418 }
419 }
420
421 fn scroll_up(&mut self, amount: u16) {
422 if self.is_contents && self.config.page.toc.enable_scrolling {
423 let i = match self.contents_state.list_state.selected() {
424 Some(i) => {
425 if i == 0 {
426 self.contents_state.max_idx_section as usize - 1
427 } else {
428 i - 1
429 }
430 }
431 None => 0,
432 };
433
434 self.contents_state.list_state.select(Some(i));
435 return;
436 }
437
438 self.scroll_to_y(self.viewport.y.saturating_sub(amount));
439 }
440
441 fn scroll_down(&mut self, amount: u16) {
442 if self.is_contents && self.config.page.toc.enable_scrolling {
443 let i = match self.contents_state.list_state.selected() {
444 Some(i) => {
445 if i >= self.contents_state.max_idx_section as usize - 1 {
446 0
447 } else {
448 i + 1
449 }
450 }
451 None => 0,
452 };
453
454 self.contents_state.list_state.select(Some(i));
455 return;
456 }
457
458 self.scroll_to_y(self.viewport.y + amount);
459 }
460
461 fn scroll_to_bottom(&mut self) {
462 let page = rendered_page!(self, self.viewport.width);
463 self.scroll_to_y(page.lines.len() as u16);
464 }
465
466 fn scroll_to_y(&mut self, y: u16) {
467 let page = rendered_page!(self, self.viewport.width);
468 let n_lines = page.lines.len() as u16;
469 self.viewport.y = y;
470
471 if self.viewport.bottom() >= n_lines {
472 self.viewport.y = n_lines.saturating_sub(self.viewport.height);
473 }
474
475 self.check_and_update_selection();
476 }
477
478 fn scroll_to_node(&mut self, idx: usize) {
479 let page = rendered_page!(self, self.viewport.width);
480 let node = match Node::new(&self.page.content, idx) {
481 Some(node) => node,
482 None => return,
483 };
484 let first_index = idx;
485 let last_index = node.last_child().map(|x| x.index()).unwrap_or(first_index);
486 let y = page.lines.iter().enumerate().find_map(|(y, line)| {
487 line.iter()
488 .find(|word| {
489 if let Some(node) = word.node(&self.page.content) {
490 first_index <= node.index() && node.index() <= last_index
491 } else {
492 false
493 }
494 })
495 .map(|_| y)
496 });
497
498 if let Some(y) = y {
499 self.scroll_to_y(y as u16);
500 }
501 }
502
503 fn check_and_update_scrolling(&mut self) {
506 let selection_y = self.selected_y() as u16;
507
508 if selection_y < self.viewport.top() {
509 self.scroll_to_y(selection_y);
510 return;
511 }
512
513 if selection_y >= self.viewport.bottom() {
514 self.scroll_to_y(selection_y.saturating_sub(self.viewport.height) + 1);
515 }
516 }
517
518 fn open_link(&self) -> ActionResult {
519 let index = self.selected.0;
520 let node = Node::new(&self.page.content, index).unwrap();
521 let data = node.data().to_owned();
522
523 let link = match data {
524 Data::Link(link) => link,
525 _ => {
526 warn!("tried to open an element that is not a link");
527 return ActionResult::Ignored;
528 }
529 };
530
531 match link {
532 Link::Internal(_) | Link::Anchor(_) => (),
533 Link::External(link_data) => return Action::PopupMessage(
534 "Warning".to_string(),
535 format!("This link doesn't point to another page. \nInstead, it leads to the following external webpage: \n\n{}", link_data.url.as_str())
536 ).into(),
537 Link::RedLink(link_data) => return Action::PopupMessage(
538 "Information".to_string(),
539 format!("The page '{}' doesn't exist yet", link_data.title)
540 ).into(),
541 Link::MediaLink(_) | Link::ExternalToInternal(_) => {
542 info!("tried to open an unsupported link '{:?}'", link);
543 return Action::PopupMessage(
544 "Information".to_string(),
545 "This type of link is not supported yet".to_string()
546 ).into()
547 }
548 }
549
550 Action::PopupDialog(
551 "Information".to_string(),
552 format!(
553 "Do you want to open the page '{}'",
554 link.title().unwrap_or("UNKNOWN")
555 ),
556 Box::<ActionPacket>::new(Action::LoadLink(link).into()),
557 )
558 .into()
559 }
560
561 fn resize(&mut self, width: u16, height: u16) {
562 self.viewport.width = width;
563 self.viewport.height = height;
564 }
565
566 fn render_status_bar(&self, f: &mut Frame<'_>, area: Rect) -> Rect {
567 let (area, status_area) = {
568 let splits = Layout::default()
569 .direction(Direction::Vertical)
570 .constraints([Constraint::Percentage(100), Constraint::Min(1)])
571 .split(padded_rect(area, 1, 1));
572 (splits[0], splits[1])
573 };
574
575 let status_msg = format!(
576 " wiki-tui | Page '{}' | Language '{}' | '{}' other languages available",
577 self.page.title,
578 self.page.language.name(),
579 self.page.available_languages().unwrap_or_default()
580 );
581 f.render_widget(
582 self.theme.default_paragraph(status_msg).style(
583 Style::default()
584 .fg(self.theme.status_bar_fg)
585 .bg(self.theme.status_bar_bg),
586 ),
587 status_area,
588 );
589
590 area
591 }
592
593 fn render_toc(&mut self, f: &mut Frame<'_>, area: Rect) -> Rect {
594 if self.config.page.toc.enabled {
595 let mut constraints = [
596 Constraint::Percentage(
597 100_u16.saturating_sub(self.config.page.toc.width_percentage),
598 ),
599 Constraint::Percentage(self.config.page.toc.width_percentage),
600 ];
601
602 if self.config.page.toc.position == TocConfigPosition::Left {
603 constraints.reverse();
604 }
605
606 let splits = Layout::default()
607 .direction(Direction::Horizontal)
608 .constraints(constraints)
609 .split(area);
610
611 match self.config.page.toc.position {
612 TocConfigPosition::Left => {
613 self.render_contents(f, splits[0]);
614 splits[1]
615 }
616 TocConfigPosition::Right => {
617 self.render_contents(f, splits[1]);
618 splits[0]
619 }
620 }
621 } else {
622 area
623 }
624 }
625
626 fn render_scrollbar(&mut self, f: &mut Frame<'_>, area: Rect, content_length: usize) {
627 let scrollbar = Scrollbar::default()
628 .begin_symbol(None)
629 .end_symbol(None)
630 .track_symbol(Some(" "))
631 .track_style(
632 Style::new()
633 .fg(self.theme.scrollbar_track_fg)
634 .bg(self.theme.scrollbar_track_fg),
635 )
636 .thumb_style(Style::new().fg(self.theme.scrollbar_thumb_fg))
637 .orientation(ScrollbarOrientation::VerticalRight);
638 let mut scrollbar_state =
639 ScrollbarState::new(content_length).position(self.viewport.top() as usize);
640 f.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
641 }
642}
643
644impl Component for PageComponent {
645 fn handle_key_events(&mut self, key: KeyEvent) -> ActionResult {
646 let page_bindings = &self.config.bindings.page;
647 macro_rules! matches_binding {
648 ($binding:ident, $action:expr) => {
649 if page_bindings.$binding.matches_event(key) {
650 return $action.into();
651 }
652 };
653 }
654 matches_binding!(toggle_toc, Action::Page(PageAction::ToggleContents));
655
656 if self.is_contents {
657 matches_binding!(jump_to_header, {
658 let header = self.selected_header();
659 if header.is_none() {
660 info!("no header selected");
661 return ActionResult::Ignored;
662 }
663 ActionPacket::single(Action::Page(PageAction::GoToHeader(
664 header.unwrap().anchor.to_string(),
665 )))
666 .action(Action::Page(PageAction::ToggleContents))
667 });
668 return ActionResult::Ignored;
669 }
670
671 matches_binding!(select_first_link, Action::Page(PageAction::SelectFirstLink));
672 matches_binding!(select_last_link, Action::Page(PageAction::SelectLastLink));
673
674 matches_binding!(select_prev_link, Action::Page(PageAction::SelectPrevLink));
675 matches_binding!(select_next_link, Action::Page(PageAction::SelectNextLink));
676
677 matches_binding!(open_link, self.open_link());
678 matches_binding!(toggle_zen_mode, {
679 self.is_zen_mode = !self.is_zen_mode;
680 ActionResult::Ignored
681 });
682
683 match key.code {
684 KeyCode::Char('r') if has_modifier!(key, Modifier::CONTROL) => {
685 Action::Page(PageAction::SwitchRenderer(self.renderer.next())).into()
686 }
687 _ => ActionResult::Ignored,
688 }
689 }
690
691 fn update(&mut self, action: Action) -> ActionResult {
692 match action {
693 Action::Page(page_action) => match page_action {
694 PageAction::SwitchRenderer(renderer) => self.switch_renderer(renderer),
695 PageAction::ToggleContents => self.is_contents = !self.is_contents,
696
697 PageAction::SelectFirstLink => self.select_first(),
698 PageAction::SelectLastLink => self.select_last(),
699
700 PageAction::SelectTopLink | PageAction::SelectBottomLink => todo!(),
701
702 PageAction::SelectPrevLink => self.select_prev(),
703 PageAction::SelectNextLink => self.select_next(),
704
705 PageAction::GoToHeader(anchor) => self.select_header(anchor),
706 },
707 Action::ScrollUp(amount) => self.scroll_up(amount),
708 Action::ScrollDown(amount) => self.scroll_down(amount),
709
710 Action::ScrollHalfUp => self.scroll_up(self.viewport.height / 2),
711 Action::ScrollHalfDown => self.scroll_down(self.viewport.height / 2),
712
713 Action::ScrollToTop => self.scroll_to_y(0),
714 Action::ScrollToBottom => self.scroll_to_bottom(),
715
716 Action::Resize(width, heigth) => self.resize(width, heigth),
717 _ => return ActionResult::Ignored,
718 }
719 ActionResult::consumed()
720 }
721
722 fn render(&mut self, f: &mut Frame, mut area: Rect) {
723 let zen_mode = self.config.page.zen_mode.clone();
724
725 area = if self.is_zen_mode() {
727 let [area] = Layout::horizontal([self.config.page.zen_horizontal])
728 .flex(Flex::Center)
729 .areas(area);
730 Layout::vertical([self.config.page.zen_vertical])
731 .flex(Flex::Center)
732 .split(area)[0]
733 } else {
734 Block::new().padding(self.config.page.padding).inner(area)
735 };
736
737 if !self.is_zen_mode || zen_mode.contains(ZenModeComponents::STATUS_BAR) {
738 area = self.render_status_bar(f, area);
739 }
740
741 if !self.is_zen_mode || zen_mode.contains(ZenModeComponents::TOC) {
742 area = self.render_toc(f, area);
743 }
744
745 let page_area = if !self.is_zen_mode || zen_mode.contains(ZenModeComponents::SCROLLBAR) {
746 area.inner(Margin {
747 vertical: 0,
748 horizontal: 2, })
750 } else {
751 area
752 };
753
754 self.viewport.width = page_area.width;
755 self.viewport.height = page_area.height;
756
757 let rendered_page = rendered_page!(self, page_area.width);
758 let mut lines: Vec<Line> = rendered_page
759 .lines
760 .iter()
761 .skip(self.viewport.top() as usize)
762 .take(self.viewport.bottom() as usize)
763 .map(|line| {
764 let mut spans: Vec<Span> = Vec::new();
765 line.iter()
766 .map(|word| {
767 let mut span = Span::styled(
768 format!(
769 "{}{}",
770 word.content,
771 " ".repeat(word.whitespace_width as usize)
772 ),
773 word.style,
774 );
775
776 if let Some(node) = word.node(&self.page.content) {
777 let index = node.index();
778 if self.selected.0 <= index && index <= self.selected.1 {
779 span = span
780 .patch_style(Style::new().add_modifier(Modifier::UNDERLINED))
781 }
782 }
783
784 spans.push(span);
785 })
786 .count();
787 Line {
788 spans,
789 ..Default::default()
790 }
791 })
792 .collect();
793
794 if self.viewport.y == 0 {
795 let title_line =
796 Line::raw(&self.page.title).patch_style(Style::default().fg(Color::Red).bold());
797
798 lines.insert(0, title_line);
799 lines.pop();
800 }
801
802 f.render_widget(Paragraph::new(lines), page_area);
803
804 if !self.is_zen_mode || zen_mode.contains(ZenModeComponents::SCROLLBAR) {
805 self.render_scrollbar(
806 f,
807 area,
808 rendered_page
809 .lines
810 .len()
811 .saturating_sub(self.viewport.height as usize),
812 )
813 }
814 }
815}