1use std::{
2 collections::{HashMap, HashSet},
3 io,
4 path::{Path, PathBuf},
5};
6
7use crossterm::{
8 event::{
9 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
10 KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
11 },
12 execute,
13};
14use ratatui::{
15 DefaultTerminal, Frame,
16 layout::{Constraint, Layout, Rect},
17 text::{Line, Span, Text},
18 widgets::{Block, Padding, Paragraph},
19};
20use ratatui_image::Image as TerminalImage;
21
22use crate::{
23 image::ImageManager,
24 input::InputKind,
25 renderer::{NodeId, RenderedDocument, RenderedLine, ViewDocument},
26 search::{SearchMatch, SearchMatcher, SearchState},
27 theme::Theme,
28 ui,
29};
30
31const MOUSE_VERTICAL_SCROLL: usize = 3;
32const MOUSE_HORIZONTAL_SCROLL: usize = 4;
33
34#[derive(Debug, Clone, Copy)]
35enum PendingPrefix {
36 Previous,
37 Next,
38}
39
40pub struct App {
41 name: String,
42 kind: InputKind,
43 base_dir: Option<PathBuf>,
44 document: ViewDocument,
45 theme: Theme,
46 search: SearchState,
47 images: ImageManager,
48
49 rendered: RenderedDocument,
50 dirty: bool,
51 last_width: u16,
52 viewport_area: Rect,
53 viewport_width: u16,
54 viewport_height: u16,
55
56 vertical_scroll: usize,
57 horizontal_scroll: usize,
58 wrap: bool,
59 tab_width: usize,
60
61 show_help: bool,
62 pending_prefix: Option<PendingPrefix>,
63}
64
65impl App {
66 pub fn new(
67 name: String,
68 kind: InputKind,
69 base_dir: Option<PathBuf>,
70 document: ViewDocument,
71 theme: Theme,
72 wrap: bool,
73 tab_width: usize,
74 ) -> Self {
75 Self {
76 name,
77 kind,
78 base_dir,
79 document,
80 theme,
81 search: SearchState::default(),
82 images: ImageManager::default(),
83 rendered: RenderedDocument::default(),
84 dirty: true,
85 last_width: 0,
86 viewport_area: Rect::default(),
87 viewport_width: 1,
88 viewport_height: 1,
89 vertical_scroll: 0,
90 horizontal_scroll: 0,
91 wrap,
92 tab_width,
93 show_help: false,
94 pending_prefix: None,
95 }
96 }
97
98 pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
99 self.images.initialize();
100 let _mouse = MouseCaptureGuard::enable()?;
101
102 loop {
103 terminal.draw(|frame| self.draw(frame))?;
104
105 match event::read()? {
106 Event::Key(key) if key.kind == KeyEventKind::Press => {
107 if self.handle_key(key) {
108 break;
109 }
110 }
111 Event::Mouse(mouse) => self.handle_mouse(mouse),
112 Event::Resize(_, _) => self.dirty = true,
113 _ => {}
114 }
115 }
116 Ok(())
117 }
118
119 fn draw(&mut self, frame: &mut Frame) {
120 let [content_area, status_area] =
121 Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(frame.area());
122
123 let block = Block::bordered()
124 .title(format!(" {} ", self.name))
125 .border_style(self.theme.status_accent)
126 .padding(Padding::uniform(1));
127 self.viewport_area = block.inner(content_area);
128 self.viewport_width = self.viewport_area.width.max(1);
129 self.viewport_height = self.viewport_area.height.max(1);
130
131 self.ensure_rendered(self.viewport_width);
132 self.clamp_scroll();
133
134 let lines = self.display_lines();
135 let text = Text::from(lines);
136 let viewer = Paragraph::new(text)
137 .style(self.theme.document)
138 .block(block)
139 .scroll((
140 self.vertical_scroll.min(u16::MAX as usize) as u16,
141 self.horizontal_scroll.min(u16::MAX as usize) as u16,
142 ));
143 frame.render_widget(viewer, content_area);
144 self.draw_images(frame);
145
146 let status_line = if self.search.active {
147 ui::search::prompt(&self.search, &self.theme)
148 } else {
149 let total = self.rendered.lines.len().max(1);
150 let visible_end = (self.vertical_scroll + self.viewport_height as usize).min(total);
151 let percent = visible_end.saturating_mul(100) / total;
152 let search_position = if self.search.query.is_empty() {
153 None
154 } else {
155 Some(
156 self.search
157 .position_label()
158 .unwrap_or((0, self.search.matches.len())),
159 )
160 };
161 ui::status::render(
162 ui::status::Status {
163 name: &self.name,
164 kind: self.kind.label(),
165 current_line: self.vertical_scroll + 1,
166 total_lines: total,
167 percent,
168 wrap: self.wrap,
169 search: search_position,
170 },
171 &self.theme,
172 )
173 };
174 frame.render_widget(
175 Paragraph::new(status_line).style(self.theme.status),
176 status_area,
177 );
178
179 if self.show_help {
180 ui::help::render(frame, &self.theme, self.document.is_markdown());
181 }
182 }
183
184 fn handle_key(&mut self, key: KeyEvent) -> bool {
185 if self.show_help {
186 match key.code {
187 KeyCode::Esc | KeyCode::Char('?') => self.show_help = false,
188 KeyCode::Char('q') => return true,
189 _ => {}
190 }
191 return false;
192 }
193
194 if self.search.active {
195 self.handle_search_key(key);
196 return false;
197 }
198
199 if let Some(prefix) = self.pending_prefix.take() {
200 self.handle_prefixed_key(prefix, key);
201 return false;
202 }
203
204 match key.code {
205 KeyCode::Char('q') => return true,
206 KeyCode::Esc => self.clear_search(),
207 KeyCode::Char('?') => self.show_help = true,
208 KeyCode::Char('/') => self.open_search(),
209 KeyCode::Char('n') => self.next_match(),
210 KeyCode::Char('N') => self.previous_match(),
211 KeyCode::Down | KeyCode::Char('j') => self.scroll_down(1),
212 KeyCode::Up | KeyCode::Char('k') => self.scroll_up(1),
213 KeyCode::Right | KeyCode::Char('l') => self.scroll_right(1),
214 KeyCode::Left | KeyCode::Char('h') => self.scroll_left(1),
215 KeyCode::PageDown => self.scroll_down(self.page_height()),
216 KeyCode::PageUp => self.scroll_up(self.page_height()),
217 KeyCode::Home | KeyCode::Char('g') => self.vertical_scroll = 0,
218 KeyCode::End | KeyCode::Char('G') => self.vertical_scroll = self.max_vertical_scroll(),
219 KeyCode::Char('w') => {
220 self.wrap = !self.wrap;
221 self.dirty = true;
222 }
223 KeyCode::Char('[') if self.document.is_markdown() => {
224 self.pending_prefix = Some(PendingPrefix::Previous)
225 }
226 KeyCode::Char(']') if self.document.is_markdown() => {
227 self.pending_prefix = Some(PendingPrefix::Next)
228 }
229 _ => {}
230 }
231 false
232 }
233
234 fn handle_prefixed_key(&mut self, prefix: PendingPrefix, key: KeyEvent) {
235 match (prefix, key.code) {
236 (PendingPrefix::Previous, KeyCode::Char('h')) => self.previous_heading(),
237 (PendingPrefix::Next, KeyCode::Char('h')) => self.next_heading(),
238 _ => {}
239 }
240 }
241
242 fn handle_search_key(&mut self, key: KeyEvent) {
243 match key.code {
244 KeyCode::Esc => {
245 self.clear_search();
246 self.search.cancel_input();
247 }
248 KeyCode::Enter => {
249 self.search.accept_input();
250 self.jump_to_current_match();
251 }
252 KeyCode::Backspace => {
253 self.search.query.pop();
254 self.refresh_search(true);
255 }
256 KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
257 self.search.query.push(ch);
258 self.refresh_search(true);
259 }
260 _ => {}
261 }
262 }
263
264 fn handle_mouse(&mut self, mouse: MouseEvent) {
265 if self.show_help {
266 return;
267 }
268
269 let shift = mouse.modifiers.contains(KeyModifiers::SHIFT);
270 match mouse.kind {
271 MouseEventKind::ScrollUp if shift => self.scroll_left(MOUSE_HORIZONTAL_SCROLL),
272 MouseEventKind::ScrollDown if shift => self.scroll_right(MOUSE_HORIZONTAL_SCROLL),
273 MouseEventKind::ScrollUp => self.scroll_up(MOUSE_VERTICAL_SCROLL),
274 MouseEventKind::ScrollDown => self.scroll_down(MOUSE_VERTICAL_SCROLL),
275 MouseEventKind::ScrollLeft => self.scroll_left(MOUSE_HORIZONTAL_SCROLL),
276 MouseEventKind::ScrollRight => self.scroll_right(MOUSE_HORIZONTAL_SCROLL),
277 MouseEventKind::Down(MouseButton::Left) => {
278 self.open_link_at(mouse.column, mouse.row);
279 }
280 _ => {}
281 }
282 }
283
284 fn open_search(&mut self) {
285 self.search.clear_query();
286 self.search.open();
287 }
288
289 fn clear_search(&mut self) {
290 self.search.clear_query();
291 }
292
293 fn refresh_search(&mut self, jump: bool) {
294 let matches = SearchMatcher::new(&self.search.query)
295 .map(|matcher| self.document.search(&matcher))
296 .unwrap_or_default();
297 self.search.set_matches(matches);
298 if jump {
299 self.jump_to_current_match();
300 }
301 }
302
303 fn next_match(&mut self) {
304 if self.search.query.is_empty() {
305 return;
306 }
307 self.search.next();
308 self.jump_to_current_match();
309 }
310
311 fn previous_match(&mut self) {
312 if self.search.query.is_empty() {
313 return;
314 }
315 self.search.previous();
316 self.jump_to_current_match();
317 }
318
319 fn jump_to_current_match(&mut self) {
320 let Some(target) = self.search.current_match().cloned() else {
321 return;
322 };
323 self.focus_search_target(&target);
324 self.clamp_scroll();
325 }
326
327 fn next_heading(&mut self) {
328 if let Some(heading) = self
329 .rendered
330 .headings
331 .iter()
332 .find(|heading| heading.line > self.vertical_scroll)
333 {
334 self.vertical_scroll = heading.line.min(self.max_vertical_scroll());
335 self.horizontal_scroll = 0;
336 }
337 }
338
339 fn previous_heading(&mut self) {
340 let target = self
341 .rendered
342 .headings
343 .iter()
344 .rev()
345 .find(|heading| heading.line < self.vertical_scroll)
346 .map(|heading| heading.line);
347 if let Some(line) = target {
348 self.vertical_scroll = line.min(self.max_vertical_scroll());
349 self.horizontal_scroll = 0;
350 }
351 }
352
353 fn open_link_at(&mut self, column: u16, row: u16) {
354 let area = self.viewport_area;
355 if column < area.x
356 || row < area.y
357 || column >= area.x.saturating_add(area.width)
358 || row >= area.y.saturating_add(area.height)
359 {
360 return;
361 }
362
363 let line = self.vertical_scroll + usize::from(row - area.y);
364 let column = self.horizontal_scroll + usize::from(column - area.x);
365
366 if let Some(destination) = self
367 .rendered
368 .images
369 .iter()
370 .find(|image| {
371 let end_line = image.line + usize::from(image.height);
372 (image.line..end_line).contains(&line) && column >= image.column
373 })
374 .map(|image| {
375 image
376 .destination
377 .clone()
378 .unwrap_or_else(|| image.source.clone())
379 })
380 {
381 self.open_destination(&destination);
382 return;
383 }
384
385 let Some(destination) = self
386 .rendered
387 .links
388 .iter()
389 .find(|link| {
390 link.line == line && column >= link.start_column && column < link.end_column
391 })
392 .map(|link| link.destination.clone())
393 else {
394 return;
395 };
396
397 self.open_destination(&destination);
398 }
399
400 fn open_destination(&mut self, destination: &str) {
401 if let Some(slug) = destination.strip_prefix('#') {
402 if let Some(heading) = self
403 .rendered
404 .headings
405 .iter()
406 .find(|heading| heading.slug == slug)
407 {
408 self.vertical_scroll = heading.line.min(self.max_vertical_scroll());
409 self.horizontal_scroll = 0;
410 }
411 return;
412 }
413
414 let _ = if has_uri_scheme(destination) || Path::new(destination).is_absolute() {
415 open::that(destination)
416 } else if let Some(base_dir) = &self.base_dir {
417 open::that(base_dir.join(destination))
418 } else {
419 open::that(destination)
420 };
421 }
422
423 fn draw_images(&mut self, frame: &mut Frame) {
424 if self.horizontal_scroll != 0 || self.rendered.images.is_empty() {
425 return;
426 }
427
428 let viewport = self.viewport_area;
429 let scroll_top = self.vertical_scroll;
430 let scroll_bottom = scroll_top + usize::from(self.viewport_height);
431 let base_dir = self.base_dir.clone();
432 let placements = self.rendered.images.clone();
433
434 for image in placements {
435 if image.line < scroll_top || image.line >= scroll_bottom {
438 continue;
439 }
440
441 let x_offset = image
442 .column
443 .min(usize::from(viewport.width.saturating_sub(1)));
444 let available_width = viewport.width.saturating_sub(x_offset as u16);
445 let y_offset = image.line - scroll_top;
446 let available_height = viewport
447 .height
448 .saturating_sub(y_offset.min(usize::from(u16::MAX)) as u16)
449 .min(image.height);
450
451 if available_width == 0 || available_height == 0 {
452 continue;
453 }
454
455 let Some(protocol) = self.images.protocol(
456 &image.source,
457 base_dir.as_deref(),
458 available_width,
459 available_height,
460 ) else {
461 continue;
462 };
463
464 let size = protocol.size();
465 let width = size.width.min(available_width);
466 let height = size.height.min(available_height);
467 let centered = available_width.saturating_sub(width) / 2;
468 let area = Rect {
469 x: viewport
470 .x
471 .saturating_add(x_offset as u16)
472 .saturating_add(centered),
473 y: viewport
474 .y
475 .saturating_add(y_offset.min(usize::from(u16::MAX)) as u16),
476 width,
477 height,
478 };
479
480 frame.render_widget(TerminalImage::new(protocol).allow_clipping(true), area);
481 }
482 }
483
484 fn ensure_rendered(&mut self, width: u16) {
485 if !self.dirty && self.last_width == width {
486 return;
487 }
488
489 let anchor = self
490 .rendered
491 .lines
492 .get(self.vertical_scroll)
493 .and_then(|line| line.source_id);
494
495 self.rendered = self.document.render(
496 width,
497 &self.theme,
498 self.wrap,
499 self.tab_width,
500 &mut self.images,
501 self.base_dir.as_deref(),
502 );
503 self.last_width = width;
504 self.dirty = false;
505
506 if let Some(id) = anchor
507 && let Some(line) = self
508 .rendered
509 .lines
510 .iter()
511 .position(|line| line.source_id == Some(id))
512 {
513 self.vertical_scroll = line;
514 }
515
516 self.clamp_scroll();
517 }
518
519 fn focus_search_target(&mut self, target: &SearchMatch) {
520 let Some(matcher) = SearchMatcher::new(&self.search.query) else {
521 return;
522 };
523 let mut occurrence = 0usize;
524 let mut fallback = None;
525
526 for (index, line) in self.rendered.lines.iter().enumerate() {
527 if line.source_id != Some(target.node_id) {
528 continue;
529 }
530 fallback.get_or_insert(index);
531 for _ in matcher.ranges(&line.plain) {
532 if occurrence == target.occurrence {
533 self.vertical_scroll = index.saturating_sub(self.viewport_height as usize / 4);
534 self.horizontal_scroll = 0;
535 return;
536 }
537 occurrence += 1;
538 }
539 }
540
541 if let Some(index) = fallback {
542 self.vertical_scroll = index.saturating_sub(self.viewport_height as usize / 4);
543 self.horizontal_scroll = 0;
544 }
545 }
546
547 fn display_lines(&self) -> Vec<Line<'static>> {
548 let Some(matcher) = SearchMatcher::new(&self.search.query) else {
549 return self
550 .rendered
551 .lines
552 .iter()
553 .map(|line| line.line.clone())
554 .collect();
555 };
556 let searchable_nodes = self
557 .search
558 .matches
559 .iter()
560 .map(|match_| match_.node_id)
561 .collect::<HashSet<_>>();
562 let current = self.search.current_match();
563 let mut occurrences = HashMap::<NodeId, usize>::new();
564
565 self.rendered
566 .lines
567 .iter()
568 .map(|line| {
569 let Some(node_id) = line.source_id else {
570 return line.line.clone();
571 };
572 if !searchable_nodes.contains(&node_id) {
573 return line.line.clone();
574 }
575 let ranges = matcher.ranges(&line.plain);
576 if ranges.is_empty() {
577 return line.line.clone();
578 }
579 let start_occurrence = *occurrences.get(&node_id).unwrap_or(&0);
580 occurrences.insert(node_id, start_occurrence + ranges.len());
581 let styled_ranges = ranges
582 .into_iter()
583 .enumerate()
584 .map(|(index, (start, end))| {
585 let occurrence = start_occurrence + index;
586 let is_current = current.is_some_and(|match_| {
587 match_.node_id == node_id && match_.occurrence == occurrence
588 });
589 (start, end, is_current)
590 })
591 .collect::<Vec<_>>();
592 highlight_line(
593 line,
594 &styled_ranges,
595 self.theme.search_match,
596 self.theme.search_current,
597 )
598 })
599 .collect()
600 }
601
602 fn scroll_down(&mut self, amount: usize) {
603 self.vertical_scroll = (self.vertical_scroll + amount).min(self.max_vertical_scroll());
604 }
605
606 fn scroll_up(&mut self, amount: usize) {
607 self.vertical_scroll = self.vertical_scroll.saturating_sub(amount);
608 }
609
610 fn scroll_right(&mut self, amount: usize) {
611 self.horizontal_scroll =
612 (self.horizontal_scroll + amount).min(self.max_horizontal_scroll());
613 }
614
615 fn scroll_left(&mut self, amount: usize) {
616 self.horizontal_scroll = self.horizontal_scroll.saturating_sub(amount);
617 }
618
619 fn page_height(&self) -> usize {
620 self.viewport_height.saturating_sub(1).max(1) as usize
621 }
622
623 fn max_vertical_scroll(&self) -> usize {
624 self.rendered
625 .lines
626 .len()
627 .saturating_sub(self.viewport_height as usize)
628 }
629
630 fn max_horizontal_scroll(&self) -> usize {
631 self.rendered
632 .max_width
633 .saturating_sub(self.viewport_width as usize)
634 }
635
636 fn clamp_scroll(&mut self) {
637 self.vertical_scroll = self.vertical_scroll.min(self.max_vertical_scroll());
638 self.horizontal_scroll = self.horizontal_scroll.min(self.max_horizontal_scroll());
639 }
640}
641
642fn has_uri_scheme(value: &str) -> bool {
643 let Some((scheme, _)) = value.split_once(':') else {
644 return false;
645 };
646 !scheme.is_empty()
647 && scheme
648 .chars()
649 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
650}
651
652fn highlight_line(
653 rendered: &RenderedLine,
654 ranges: &[(usize, usize, bool)],
655 match_style: ratatui::style::Style,
656 current_style: ratatui::style::Style,
657) -> Line<'static> {
658 let span_len = rendered
659 .line
660 .spans
661 .iter()
662 .map(|span| span.content.as_ref().len())
663 .sum::<usize>();
664 if span_len != rendered.plain.len() {
665 return rendered.line.clone();
666 }
667
668 let mut output = Vec::<Span<'static>>::new();
669 let mut global_offset = 0usize;
670
671 for span in &rendered.line.spans {
672 let text = span.content.as_ref();
673 let span_start = global_offset;
674 let span_end = span_start + text.len();
675 let mut cuts = vec![0usize, text.len()];
676
677 for (start, end, _) in ranges {
678 if *start < span_end && *end > span_start {
679 cuts.push(start.saturating_sub(span_start).min(text.len()));
680 cuts.push(end.saturating_sub(span_start).min(text.len()));
681 }
682 }
683 cuts.sort_unstable();
684 cuts.dedup();
685
686 for pair in cuts.windows(2) {
687 let local_start = pair[0];
688 let local_end = pair[1];
689 if local_start == local_end
690 || !text.is_char_boundary(local_start)
691 || !text.is_char_boundary(local_end)
692 {
693 continue;
694 }
695 let global_start = span_start + local_start;
696 let global_end = span_start + local_end;
697 let mut style = span.style;
698 if let Some((_, _, is_current)) = ranges
699 .iter()
700 .find(|(start, end, _)| global_start >= *start && global_end <= *end)
701 {
702 style = style.patch(if *is_current {
703 current_style
704 } else {
705 match_style
706 });
707 }
708 output.push(Span::styled(
709 text[local_start..local_end].to_string(),
710 style,
711 ));
712 }
713 global_offset = span_end;
714 }
715
716 let mut line = rendered.line.clone();
717 line.spans = output;
718 line
719}
720
721struct MouseCaptureGuard;
722
723impl MouseCaptureGuard {
724 fn enable() -> io::Result<Self> {
725 execute!(io::stdout(), EnableMouseCapture)?;
726 Ok(Self)
727 }
728}
729
730impl Drop for MouseCaptureGuard {
731 fn drop(&mut self) {
732 let _ = execute!(io::stdout(), DisableMouseCapture);
733 }
734}