Skip to main content

iris_reader/
app.rs

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            // Fixed image protocols cannot be vertically offset cheaply. Once the top of an image
436            // scrolls above the viewport, leave the reserved rows in place until it is fully passed.
437            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}