Skip to main content

mdcat/mdless/
view.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//! Viewport and draw loop.
6//!
7//! [`View`] owns the scroll offset and size. [`draw`](View::draw)
8//! emits the next frame; [`draw_toc`](View::draw_toc) replaces the
9//! body when the TOC modal is open.
10
11use std::io::{self, Write};
12
13use super::buffer::{HeadingEntry, RenderedDoc};
14use super::highlight::{self, Highlight};
15use super::keys::Command;
16use super::search::Match;
17use super::toc::Toc;
18
19/// Scroll state bound to a terminal size.
20#[derive(Debug, Clone, Copy)]
21#[allow(missing_docs)]
22pub struct View {
23    /// Zero-indexed rendered line at the top of the viewport.
24    pub top: usize,
25    pub cols: u16,
26    pub rows: u16,
27    /// Dim 1-indexed line-number gutter. Toggled live with `#`.
28    pub line_numbers: bool,
29}
30
31/// Columns the gutter reserves: `NNN │ ` = digits(3) + separator(3).
32pub const GUTTER: u16 = 6;
33
34impl View {
35    /// Viewport at `(cols, rows)`, scrolled to the top.
36    pub fn new(cols: u16, rows: u16) -> Self {
37        Self {
38            top: 0,
39            cols,
40            rows,
41            line_numbers: false,
42        }
43    }
44
45    /// Builder toggle for the line-number gutter.
46    pub fn with_line_numbers(mut self, on: bool) -> Self {
47        self.line_numbers = on;
48        self
49    }
50
51    /// Body rows available above the status line; at least 1.
52    fn body_rows(&self) -> usize {
53        self.rows.saturating_sub(1).max(1) as usize
54    }
55
56    fn max_top(&self, doc: &RenderedDoc) -> usize {
57        doc.line_count().saturating_sub(self.body_rows())
58    }
59
60    /// Apply `cmd` to the scroll state. Returns `true` on `Quit`.
61    ///
62    /// Search / highlight / redraw commands are handled by the event
63    /// loop; this method only reacts to scroll commands.
64    pub fn apply(&mut self, cmd: Command, doc: &RenderedDoc) -> bool {
65        let max = self.max_top(doc);
66        let body = self.body_rows();
67        match cmd {
68            Command::Quit => return true,
69            Command::ScrollDown(n) => self.top = (self.top + n as usize).min(max),
70            Command::ScrollUp(n) => self.top = self.top.saturating_sub(n as usize),
71            Command::PageDown => self.top = (self.top + body).min(max),
72            Command::PageUp => self.top = self.top.saturating_sub(body),
73            Command::HalfPageDown => self.top = (self.top + body / 2).min(max),
74            Command::HalfPageUp => self.top = self.top.saturating_sub(body / 2),
75            Command::Home => self.top = 0,
76            Command::End => self.top = max,
77            Command::GotoLine(n) => self.top = n.saturating_sub(1).min(max),
78            _ => {}
79        }
80        false
81    }
82
83    /// Scroll so `line` sits near the top, leaving a two-line breadcrumb
84    /// above it when the document has room. Jumps for search / heading
85    /// navigation use this; bookmarks want exact placement and call
86    /// [`jump_to`](Self::jump_to) instead.
87    pub fn scroll_to(&mut self, line: usize, doc: &RenderedDoc) {
88        self.top = line.saturating_sub(2).min(self.max_top(doc));
89    }
90
91    /// Place `line` at the exact top of the viewport, clamped to the doc.
92    pub fn jump_to(&mut self, line: usize, doc: &RenderedDoc) {
93        self.top = line.min(self.max_top(doc));
94    }
95
96    /// Update size after a terminal resize and clamp `top` to the new end.
97    pub fn resize(&mut self, cols: u16, rows: u16, doc: &RenderedDoc) {
98        self.cols = cols;
99        self.rows = rows;
100        self.top = self.top.min(self.max_top(doc));
101    }
102
103    /// Render the visible document slice + status line to `out`.
104    ///
105    /// `status` controls the bottom row: `None` draws the default
106    /// position indicator, `Some("…")` draws whatever the caller
107    /// supplies (used for search prompt and search summaries).
108    /// `matches` lists highlight ranges (plain byte ranges) across the
109    /// whole document; the draw routine filters per-line and maps into
110    /// styled byte offsets via `RenderedDoc::line_starts`.
111    pub fn draw<W: Write>(
112        &self,
113        out: &mut W,
114        doc: &RenderedDoc,
115        matches: &[Match],
116        current: Option<&Match>,
117        status: Option<&str>,
118    ) -> io::Result<()> {
119        out.write_all(b"\x1b[H\x1b[0J")?;
120
121        let body = self.body_rows();
122        // Gutter width scales to the document so 1M-line docs still fit.
123        let gutter_width = if self.line_numbers {
124            digit_count(doc.line_count()).max(3)
125        } else {
126            0
127        };
128        for row in 0..body {
129            // Reset SGR before each row so style leaks from multi-line
130            // blocks (HTML colouring, code-block box fills) don't stain
131            // the gutter or the next line's prose.
132            out.write_all(b"\x1b[0m")?;
133            let line_index = self.top + row;
134            let past_eof = line_index >= doc.line_count();
135            if self.line_numbers {
136                let n = if past_eof { None } else { Some(line_index + 1) };
137                write_gutter(out, gutter_width, n)?;
138            }
139            if past_eof {
140                out.write_all(b"\r\n")?;
141                continue;
142            }
143            let line_bytes = doc.styled_line(line_index);
144            // Raw mode needs CR before LF, otherwise the cursor stays
145            // in the previous column and every subsequent row is
146            // indented further right. Strip the renderer's trailing
147            // `\n` and emit `\r\n` ourselves.
148            let content = line_bytes.strip_suffix(b"\n").unwrap_or(line_bytes);
149            let hl = line_highlights(doc, line_index, matches, current);
150            highlight::write_line(out, content, &hl)?;
151            out.write_all(b"\r\n")?;
152        }
153
154        self.draw_status(out, doc, status)?;
155        out.flush()
156    }
157
158    /// Render the TOC modal: full-frame heading list with the selected
159    /// row reversed, plus a status line prompting navigation keys.
160    pub fn draw_toc<W: Write>(
161        &self,
162        out: &mut W,
163        headings: &[HeadingEntry],
164        toc: &Toc,
165    ) -> io::Result<()> {
166        out.write_all(b"\x1b[H\x1b[0J")?;
167        let body = self.body_rows();
168        toc.draw(out, headings, body)?;
169        out.write_all(b"\x1b[7m")?;
170        if headings.is_empty() {
171            out.write_all(b"-- TOC --  (document has no headings)  Esc:close")?;
172        } else {
173            write!(
174                out,
175                "-- TOC --  {}/{}  Enter:jump  Esc/T:close  j/k:move",
176                toc.selected + 1,
177                headings.len(),
178            )?;
179        }
180        out.write_all(b"\x1b[0m")?;
181        out.flush()
182    }
183
184    fn draw_status<W: Write>(
185        &self,
186        out: &mut W,
187        doc: &RenderedDoc,
188        status: Option<&str>,
189    ) -> io::Result<()> {
190        out.write_all(b"\x1b[7m")?;
191        match status {
192            Some(text) => out.write_all(text.as_bytes())?,
193            None => {
194                let body = self.body_rows();
195                let percent = if doc.line_count() <= body {
196                    100
197                } else {
198                    ((self.top + body).min(doc.line_count()) * 100 / doc.line_count()).min(100)
199                };
200                write!(
201                    out,
202                    "-- mdless --  line {}/{} ({percent}%)  q:quit  /:search  ]]:next  T:toc  m/':mark",
203                    self.top + 1,
204                    doc.line_count(),
205                )?;
206            }
207        }
208        out.write_all(b"\x1b[0m")
209    }
210}
211
212/// Decimal digit count of `n`, with a floor of 1.
213fn digit_count(n: usize) -> usize {
214    n.checked_ilog10().map_or(1, |log| log as usize + 1)
215}
216
217/// Write one row's gutter. `number = None` leaves a blank field (past-EOF
218/// rows) so the separator column stays aligned. Dim SGR keeps the number
219/// quiet enough to read as chrome rather than content.
220fn write_gutter<W: Write>(out: &mut W, width: usize, number: Option<usize>) -> io::Result<()> {
221    match number {
222        Some(n) => write!(out, "\x1b[2m{n:>width$} │\x1b[0m "),
223        None => write!(out, "\x1b[2m{:>width$} │\x1b[0m ", ""),
224    }
225}
226
227/// Collect the highlight ranges that intersect `line`, translated to
228/// line-local styled byte offsets.
229fn line_highlights(
230    doc: &RenderedDoc,
231    line: usize,
232    matches: &[Match],
233    current: Option<&Match>,
234) -> Highlight {
235    let line_start = doc.styled_line_starts[line];
236    let mut hl = Highlight::default();
237    for m in matches {
238        if m.line != line {
239            continue;
240        }
241        let local = (m.styled.start - line_start)..(m.styled.end - line_start);
242        if current.is_some_and(|c| std::ptr::eq(c, m)) {
243            hl.current = Some(local);
244        } else {
245            hl.others.push(local);
246        }
247    }
248    hl
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use crate::mdless::buffer;
255
256    fn doc(n_lines: usize) -> RenderedDoc {
257        use std::fmt::Write;
258        let mut styled = String::new();
259        for i in 0..n_lines {
260            writeln!(styled, "line {i}").unwrap();
261        }
262        buffer::build(styled.into_bytes(), Vec::new())
263    }
264
265    #[test]
266    fn scroll_down_clamps_at_end() {
267        let d = doc(10);
268        let mut v = View::new(80, 5); // 4 body rows + 1 status
269        v.apply(Command::ScrollDown(100), &d);
270        assert_eq!(v.top, 10 - 4);
271    }
272
273    #[test]
274    fn page_down_moves_by_body_rows() {
275        let d = doc(20);
276        let mut v = View::new(80, 6); // body = 5
277        v.apply(Command::PageDown, &d);
278        assert_eq!(v.top, 5);
279    }
280
281    #[test]
282    fn goto_line_uses_one_indexed_input() {
283        let d = doc(10);
284        let mut v = View::new(80, 5);
285        v.apply(Command::GotoLine(7), &d);
286        assert_eq!(v.top, 6);
287    }
288
289    #[test]
290    fn home_and_end_flip_between_boundaries() {
291        let d = doc(50);
292        let mut v = View::new(80, 10);
293        v.apply(Command::End, &d);
294        assert_eq!(v.top, 50 - 9);
295        v.apply(Command::Home, &d);
296        assert_eq!(v.top, 0);
297    }
298
299    #[test]
300    fn draw_emits_first_body_lines_and_status() {
301        let d = doc(10);
302        let v = View::new(80, 4); // 3 body rows
303        let mut out = Vec::new();
304        v.draw(&mut out, &d, &[], None, None).unwrap();
305        let s = String::from_utf8(out).unwrap();
306        // Lines emitted CR-LF-terminated for raw mode.
307        assert!(s.contains("line 0\r\n"));
308        assert!(s.contains("line 1\r\n"));
309        assert!(s.contains("line 2\r\n"));
310        assert!(!s.contains("line 3"));
311        assert!(s.contains("line 1/10"));
312    }
313
314    #[test]
315    fn draw_with_custom_status_uses_it() {
316        let d = doc(5);
317        let v = View::new(80, 4);
318        let mut out = Vec::new();
319        v.draw(&mut out, &d, &[], None, Some("/needle_")).unwrap();
320        let s = String::from_utf8(out).unwrap();
321        assert!(s.contains("/needle_"));
322        assert!(!s.contains("-- mdless --"));
323    }
324
325    #[test]
326    fn scroll_to_places_line_near_top_with_breadcrumb() {
327        let d = doc(40);
328        let mut v = View::new(80, 10);
329        v.scroll_to(15, &d);
330        assert_eq!(v.top, 13);
331    }
332
333    #[test]
334    fn draw_with_line_numbers_prefixes_each_row() {
335        let d = doc(12);
336        let v = View::new(80, 4).with_line_numbers(true); // 3 body rows
337        let mut out = Vec::new();
338        v.draw(&mut out, &d, &[], None, None).unwrap();
339        let s = String::from_utf8(out).unwrap();
340        // Two-digit gutter (document has 12 lines, floor is 3 columns).
341        // SGR reset closes the dim effect between gutter and body text.
342        assert!(s.contains("  1 │\x1b[0m line 0"), "row 1: {s}");
343        assert!(s.contains("  2 │\x1b[0m line 1"), "row 2: {s}");
344        assert!(s.contains("  3 │\x1b[0m line 2"), "row 3: {s}");
345    }
346
347    #[test]
348    fn resize_clamps_top() {
349        let d = doc(10);
350        let mut v = View::new(80, 5);
351        v.apply(Command::End, &d);
352        assert_eq!(v.top, 6);
353        v.resize(80, 20, &d); // body_rows grows; top clamps down.
354        assert_eq!(v.top, 0);
355    }
356
357    #[test]
358    fn apply_returns_true_on_quit() {
359        let d = doc(1);
360        let mut v = View::new(80, 5);
361        assert!(v.apply(Command::Quit, &d));
362    }
363}