1use 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#[derive(Debug, Clone, Copy)]
21#[allow(missing_docs)]
22pub struct View {
23 pub top: usize,
25 pub cols: u16,
26 pub rows: u16,
27 pub line_numbers: bool,
29}
30
31pub const GUTTER: u16 = 6;
33
34impl View {
35 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 pub fn with_line_numbers(mut self, on: bool) -> Self {
47 self.line_numbers = on;
48 self
49 }
50
51 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 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 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 pub fn jump_to(&mut self, line: usize, doc: &RenderedDoc) {
93 self.top = line.min(self.max_top(doc));
94 }
95
96 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 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 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 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 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 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
212fn digit_count(n: usize) -> usize {
214 n.checked_ilog10().map_or(1, |log| log as usize + 1)
215}
216
217fn 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
227fn 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); 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); 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); let mut out = Vec::new();
304 v.draw(&mut out, &d, &[], None, None).unwrap();
305 let s = String::from_utf8(out).unwrap();
306 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); let mut out = Vec::new();
338 v.draw(&mut out, &d, &[], None, None).unwrap();
339 let s = String::from_utf8(out).unwrap();
340 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); 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}