Skip to main content

mdcat/mdless/
toc.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5//! TOC modal.
6//!
7//! [`Toc`] tracks the selected heading. Entries are
8//! [`HeadingEntry`]s from the render pass.
9
10use std::io::{self, Write};
11
12use super::buffer::HeadingEntry;
13
14/// State of an open TOC modal.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct Toc {
17    /// Index into `RenderedDoc::headings` of the highlighted entry.
18    pub selected: usize,
19}
20
21impl Toc {
22    /// New modal with the first heading selected.
23    pub fn new(_headings: &[HeadingEntry]) -> Self {
24        Self::default()
25    }
26
27    /// Move the selection by `delta`, clamped to the heading count.
28    pub fn step(&mut self, delta: isize, total: usize) {
29        if total == 0 {
30            self.selected = 0;
31            return;
32        }
33        let max = (total - 1) as isize;
34        let next = self.selected as isize + delta;
35        self.selected = next.clamp(0, max) as usize;
36    }
37
38    /// Draw `rows` heading rows, reverse-video marking the selected row.
39    ///
40    /// Scrolls the list so the selection stays on-screen; entries past
41    /// the end render as blank rows.
42    pub fn draw<W: Write>(
43        &self,
44        out: &mut W,
45        headings: &[HeadingEntry],
46        rows: usize,
47    ) -> io::Result<()> {
48        // Keep the selection in the top half of the modal so new users
49        // see the next few headings at a glance.
50        let top = self.selected.saturating_sub(rows / 2);
51        for row in 0..rows {
52            let idx = top + row;
53            if let Some(h) = headings.get(idx) {
54                let indent = " ".repeat(usize::from(h.level.saturating_sub(1)) * 2);
55                if idx == self.selected {
56                    write!(out, "\x1b[7m{indent}{}\x1b[0m\r\n", h.text)?;
57                } else {
58                    write!(out, "{indent}{}\r\n", h.text)?;
59                }
60            } else {
61                out.write_all(b"\r\n")?;
62            }
63        }
64        Ok(())
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    fn entries(n: usize) -> Vec<HeadingEntry> {
73        (0..n)
74            .map(|i| HeadingEntry {
75                level: 1,
76                text: format!("heading {i}"),
77                plain_offset: i * 10,
78            })
79            .collect()
80    }
81
82    #[test]
83    fn step_clamps_to_bounds() {
84        let hs = entries(3);
85        let mut t = Toc::new(&hs);
86        t.step(-5, hs.len());
87        assert_eq!(t.selected, 0);
88        t.step(10, hs.len());
89        assert_eq!(t.selected, 2);
90    }
91
92    #[test]
93    fn draw_marks_selected_entry_with_reverse_sgr() {
94        let hs = entries(3);
95        let mut t = Toc::new(&hs);
96        t.selected = 1;
97        let mut out = Vec::new();
98        t.draw(&mut out, &hs, 3).unwrap();
99        let s = String::from_utf8(out).unwrap();
100        assert!(s.contains("\x1b[7mheading 1"));
101        assert!(s.contains("heading 0"));
102        assert!(s.contains("heading 2"));
103    }
104}