1pub mod buffer;
13pub mod highlight;
14pub mod keys;
15pub mod search;
16pub mod toc;
17pub mod view;
18
19use std::collections::HashMap;
20use std::io::{self, BufWriter, Write};
21use std::path::Path;
22
23use anyhow::{Context, Result};
24use crossterm::cursor::{Hide, Show};
25use crossterm::event::{self, Event};
26use crossterm::terminal::{
27 disable_raw_mode, enable_raw_mode, size, EnterAlternateScreen, LeaveAlternateScreen,
28};
29use crossterm::{execute, queue};
30use pulldown_cmark::Parser;
31
32use buffer::{build, HeadingRecorder, RenderedDoc};
33use keys::{Command, Decoder, SearchDirection};
34use search::{CaseMode, Direction, SearchState};
35use toc::Toc;
36use view::View;
37
38use crate::args::CommonArgs;
39use crate::resources::ResourceUrlHandler;
40use crate::terminal::capabilities::TerminalCapabilities;
41use crate::{read_input, Environment, Multiplexer, Settings, TerminalProgram, Theme};
42
43#[derive(Debug, Clone, Default)]
45pub struct MdlessOptions {
46 pub initial: Option<String>,
48 pub case_sensitive: bool,
50 pub regex: bool,
52 pub line_numbers: bool,
54}
55
56struct Session {
58 doc: RenderedDoc,
59 view: View,
60 decoder: Decoder,
61 search: Option<SearchState>,
63 direction: SearchDirection,
65 input: String,
67 status: Option<String>,
70 regex: bool,
72 case: CaseMode,
73 toc: Option<Toc>,
75 bookmarks: HashMap<char, usize>,
78}
79
80impl Session {
81 fn matches(&self) -> &[search::Match] {
82 self.search.as_ref().map_or(&[][..], SearchState::all)
83 }
84
85 fn current_match(&self) -> Option<&search::Match> {
86 self.search.as_ref().and_then(SearchState::current)
87 }
88}
89
90pub fn run(
95 filename: &str,
96 common: &CommonArgs,
97 opts: MdlessOptions,
98 resource_handler: &dyn ResourceUrlHandler,
99) -> Result<i32> {
100 let doc = render_doc(filename, common, opts.line_numbers, resource_handler)?;
101 let (cols, rows) = size().unwrap_or((80, 24));
102
103 let mut session = Session {
104 doc,
105 view: View::new(cols, rows).with_line_numbers(opts.line_numbers),
106 decoder: Decoder::default(),
107 search: None,
108 direction: SearchDirection::Forward,
109 input: String::new(),
110 status: None,
111 regex: opts.regex,
112 case: if opts.case_sensitive {
113 CaseMode::Sensitive
114 } else {
115 CaseMode::Smart
116 },
117 toc: None,
118 bookmarks: HashMap::new(),
119 };
120
121 if let Some(initial) = opts.initial {
123 apply_search(&mut session, initial);
124 }
125
126 let _guard = TerminalGuard::enter()?;
127 let mut out = BufWriter::new(io::stdout());
128 draw(&session, &mut out)?;
129
130 loop {
131 match event::read()? {
132 Event::Key(key) if key.kind == event::KeyEventKind::Press => {
133 let cmd = session.decoder.feed(key);
134 if dispatch_cmd(&mut session, cmd) {
135 break;
136 }
137 draw(&session, &mut out)?;
138 }
139 Event::Resize(cols, rows) => {
140 session.view.resize(cols, rows, &session.doc);
141 draw(&session, &mut out)?;
142 }
143 _ => {}
144 }
145 }
146 Ok(0)
147}
148
149fn dispatch_cmd(s: &mut Session, cmd: Command) -> bool {
151 if s.toc.is_some() {
152 return dispatch_toc(s, cmd);
153 }
154 match cmd {
155 Command::Noop => false,
156 Command::Quit => true,
157 Command::Redraw => false,
158 Command::BeginSearch(dir) => {
159 s.direction = dir;
160 s.input.clear();
161 s.status = Some(prompt_for(dir).to_string());
162 false
163 }
164 Command::SearchChar(c) => {
165 s.input.push(c);
166 s.status = Some(format!("{}{}", prompt_for(s.direction), s.input));
167 false
168 }
169 Command::SearchBackspace => {
170 s.input.pop();
171 s.status = Some(format!("{}{}", prompt_for(s.direction), s.input));
172 false
173 }
174 Command::SearchCommit => {
175 let pattern = std::mem::take(&mut s.input);
176 if pattern.is_empty() {
177 s.status = None;
178 } else {
179 apply_search(s, pattern);
180 }
181 false
182 }
183 Command::SearchCancel | Command::ClearHighlights => {
184 s.search = None;
185 s.input.clear();
186 s.status = None;
187 false
188 }
189 Command::SearchNext => {
190 step_search(s, Direction::Forward);
191 false
192 }
193 Command::SearchPrev => {
194 step_search(s, Direction::Backward);
195 false
196 }
197 Command::NextHeading => {
198 jump_heading(s, Direction::Forward);
199 false
200 }
201 Command::PrevHeading => {
202 jump_heading(s, Direction::Backward);
203 false
204 }
205 Command::ToggleToc => {
206 s.toc = Some(Toc::new(&s.doc.headings));
207 s.status = None;
208 false
209 }
210 Command::SetBookmark(c) => {
211 s.bookmarks.insert(c, s.view.top);
212 s.status = Some(format!("bookmark {c} set at line {}", s.view.top + 1));
213 false
214 }
215 Command::JumpBookmark(c) => {
216 if let Some(&line) = s.bookmarks.get(&c) {
217 s.view.jump_to(line, &s.doc);
218 s.status = None;
219 } else {
220 s.status = Some(format!("no bookmark `{c}`"));
221 }
222 false
223 }
224 Command::ToggleLineNumbers => {
225 s.view.line_numbers = !s.view.line_numbers;
226 false
227 }
228 Command::TocActivate => false,
230 other => {
231 s.view.apply(other, &s.doc);
232 false
233 }
234 }
235}
236
237fn dispatch_toc(s: &mut Session, cmd: Command) -> bool {
243 let total = s.doc.headings.len();
244 match cmd {
245 Command::Quit => true,
246 Command::ToggleToc | Command::ClearHighlights | Command::SearchCancel => {
247 s.toc = None;
248 false
249 }
250 Command::ScrollDown(n) => {
251 if let Some(t) = s.toc.as_mut() {
252 t.step(isize::from(i16::try_from(n).unwrap_or(i16::MAX)), total);
253 }
254 false
255 }
256 Command::ScrollUp(n) => {
257 if let Some(t) = s.toc.as_mut() {
258 t.step(-isize::from(i16::try_from(n).unwrap_or(i16::MAX)), total);
259 }
260 false
261 }
262 Command::Home => {
263 if let Some(t) = s.toc.as_mut() {
264 t.selected = 0;
265 }
266 false
267 }
268 Command::End => {
269 if let Some(t) = s.toc.as_mut() {
270 t.selected = total.saturating_sub(1);
271 }
272 false
273 }
274 Command::TocActivate => {
275 let target = s.toc.as_ref().and_then(|t| s.doc.headings.get(t.selected));
276 if let Some(h) = target {
277 let line = s.doc.line_for_plain_offset(h.plain_offset);
278 s.view.scroll_to(line, &s.doc);
279 }
280 s.toc = None;
281 false
282 }
283 _ => false,
284 }
285}
286
287fn jump_heading(s: &mut Session, dir: Direction) {
292 let top = s.view.top;
293 let target = match dir {
294 Direction::Forward => s
295 .doc
296 .headings
297 .iter()
298 .map(|h| s.doc.line_for_plain_offset(h.plain_offset))
299 .find(|&line| line > top),
300 Direction::Backward => s
301 .doc
302 .headings
303 .iter()
304 .rev()
305 .map(|h| s.doc.line_for_plain_offset(h.plain_offset))
306 .find(|&line| line < top),
307 };
308 if let Some(line) = target {
309 s.view.scroll_to(line, &s.doc);
310 } else {
311 s.status = Some(match dir {
312 Direction::Forward => "no next heading".to_string(),
313 Direction::Backward => "no previous heading".to_string(),
314 });
315 }
316}
317
318fn prompt_for(dir: SearchDirection) -> &'static str {
320 match dir {
321 SearchDirection::Forward => "/",
322 SearchDirection::Backward => "?",
323 }
324}
325
326fn apply_search(s: &mut Session, pattern: String) {
328 let mut state = match SearchState::compile(&s.doc, &pattern, s.regex, s.case) {
329 Ok(state) => state,
330 Err(error) => {
331 s.status = Some(format!("{error}"));
332 return;
333 }
334 };
335 let initial = match s.direction {
336 SearchDirection::Forward => Direction::Forward,
337 SearchDirection::Backward => Direction::Backward,
338 };
339 let jump = state
340 .current()
341 .map(|m| m.line)
342 .or_else(|| state.step(initial).map(|(m, _)| m.line));
343 let total = state.len();
344 s.search = Some(state);
345 s.status = Some(if total == 0 {
346 format!("Pattern not found: {pattern}")
347 } else {
348 format!("{total} matches n/N:next/prev Esc:clear")
349 });
350 if let Some(line) = jump {
351 s.view.scroll_to(line, &s.doc);
352 }
353}
354
355fn step_search(s: &mut Session, dir: Direction) {
357 let Some(state) = s.search.as_mut() else {
358 return;
359 };
360 if let Some((m, wrapped)) = state.step(dir) {
361 if wrapped {
362 s.status = Some("search wrapped".to_string());
363 }
364 s.view.scroll_to(m.line, &s.doc);
365 }
366}
367
368fn draw<W: Write>(s: &Session, out: &mut W) -> io::Result<()> {
370 match s.toc.as_ref() {
371 Some(toc) => s.view.draw_toc(out, &s.doc.headings, toc),
372 None => s.view.draw(
373 out,
374 &s.doc,
375 s.matches(),
376 s.current_match(),
377 s.status.as_deref(),
378 ),
379 }
380}
381
382const MAX_RENDER_COLS: u16 = 120;
385
386fn render_doc(
389 filename: &str,
390 common: &CommonArgs,
391 line_numbers: bool,
392 resource_handler: &dyn ResourceUrlHandler,
393) -> Result<RenderedDoc> {
394 let (base_dir, input) = read_input(filename)?;
395 let parser = Parser::new_ext(&input, crate::markdown_options());
396 let env =
397 Environment::for_local_directory(&base_dir).context("build environment for mdless")?;
398
399 let (cols, _rows) = size().unwrap_or((80, 24));
400 let reserved = if line_numbers { view::GUTTER } else { 2 };
404 let columns = common
405 .columns
406 .unwrap_or_else(|| cols.saturating_sub(reserved).clamp(20, MAX_RENDER_COLS));
407 let terminal_size = crate::TerminalSize::default().with_max_columns(columns);
408
409 let syntax_set = syntect::parsing::SyntaxSet::load_defaults_newlines();
410 let settings = Settings {
411 terminal_capabilities: ansi_without_images(),
412 terminal_size,
413 multiplexer: Multiplexer::None,
414 syntax_set: &syntax_set,
415 theme: Theme::default(),
416 wrap_code: common.wrap_code,
417 };
418
419 let mut styled = Vec::with_capacity(input.len() * 2);
420 let mut recorder = HeadingRecorder::default();
421 crate::push_tty_with_observer(
422 &settings,
423 &env,
424 resource_handler,
425 &mut styled,
426 parser,
427 &mut recorder,
428 )
429 .with_context(|| format!("rendering {}", Path::new(filename).display()))?;
430
431 Ok(build(styled, recorder.finish()))
432}
433
434fn ansi_without_images() -> TerminalCapabilities {
436 let mut caps = TerminalProgram::Ansi.capabilities();
437 caps.image = None;
438 caps
439}
440
441struct TerminalGuard;
444
445impl TerminalGuard {
446 fn enter() -> Result<Self> {
447 enable_raw_mode().context("enable raw mode")?;
448 execute!(io::stdout(), EnterAlternateScreen, Hide).context("enter alternate screen")?;
449 install_panic_hook();
450 Ok(Self)
451 }
452}
453
454fn install_panic_hook() {
459 use std::sync::Once;
460 static HOOK: Once = Once::new();
461 HOOK.call_once(|| {
462 let previous = std::panic::take_hook();
463 std::panic::set_hook(Box::new(move |info| {
464 let mut out = io::stdout();
465 let _ = queue!(out, Show, LeaveAlternateScreen);
466 let _ = out.flush();
467 let _ = disable_raw_mode();
468 previous(info);
469 }));
470 });
471}
472
473impl Drop for TerminalGuard {
474 fn drop(&mut self) {
475 let mut out = io::stdout();
477 let _ = queue!(out, Show, LeaveAlternateScreen);
478 let _ = out.flush();
479 let _ = disable_raw_mode();
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use std::collections::HashMap;
486
487 use super::buffer::build;
488 use super::keys::{Command, Decoder};
489 use super::view::View;
490 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
491
492 fn doc(lines: &[&str]) -> super::RenderedDoc {
493 let styled = lines
494 .iter()
495 .flat_map(|l| l.as_bytes().iter().copied().chain(std::iter::once(b'\n')))
496 .collect();
497 build(styled, Vec::new())
498 }
499
500 #[test]
503 fn scripted_keystrokes_drive_viewport() {
504 let d = doc(&[
505 "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
506 ]);
507 let mut v = View::new(80, 4); let mut dec = Decoder::default();
509
510 let script = [
511 (KeyCode::Char('j'), 1),
512 (KeyCode::Char('j'), 2),
513 (KeyCode::Char(' '), 5),
514 (KeyCode::Char('k'), 4),
515 (KeyCode::Char('G'), 7),
516 (KeyCode::Char('g'), 7), (KeyCode::Char('g'), 0), ];
519 for (code, expected_top) in script {
520 let cmd = dec.feed(KeyEvent::new(code, KeyModifiers::NONE));
521 v.apply(cmd, &d);
522 assert_eq!(v.top, expected_top, "after {code:?}");
523 }
524
525 let quit_cmd = dec.feed(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
526 assert!(v.apply(quit_cmd, &d));
527 }
528
529 #[test]
531 fn resize_clamp_preserves_visibility() {
532 let d = doc(&["a"; 20]);
533 let mut v = View::new(80, 10);
534 v.apply(Command::End, &d); v.resize(80, 80, &d); assert_eq!(v.top, 0);
537 }
538
539 fn session_with_headings(
545 lines: &[&str],
546 headings: Vec<super::buffer::HeadingEntry>,
547 ) -> super::Session {
548 let styled: Vec<u8> = lines
549 .iter()
550 .flat_map(|l| l.as_bytes().iter().copied().chain(std::iter::once(b'\n')))
551 .collect();
552 let doc = build(styled, headings);
553 super::Session {
554 doc,
555 view: View::new(80, 5),
556 decoder: Decoder::default(),
557 search: None,
558 direction: super::SearchDirection::Forward,
559 input: String::new(),
560 status: None,
561 regex: false,
562 case: super::CaseMode::Smart,
563 toc: None,
564 bookmarks: HashMap::new(),
565 }
566 }
567
568 fn press(s: &mut super::Session, c: char) {
570 let cmd = s
571 .decoder
572 .feed(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
573 let _ = super::dispatch_cmd(s, cmd);
574 }
575
576 #[test]
578 fn double_bracket_jumps_to_next_heading() {
579 let lines = (0..20)
580 .map(|i| if i == 5 { "## Sub" } else { "body" })
581 .collect::<Vec<_>>();
582 let offset = (0..5).map(|_| "body".len() + 1).sum::<usize>();
584 let headings = vec![super::buffer::HeadingEntry {
585 level: 2,
586 text: "Sub".to_string(),
587 plain_offset: offset,
588 }];
589 let mut s = session_with_headings(&lines, headings);
590 super::jump_heading(&mut s, super::Direction::Forward);
591 assert_eq!(s.view.top, 3);
593 }
594
595 #[test]
597 fn t_opens_toc_modal() {
598 let headings = vec![
599 super::buffer::HeadingEntry {
600 level: 1,
601 text: "Intro".to_string(),
602 plain_offset: 0,
603 },
604 super::buffer::HeadingEntry {
605 level: 2,
606 text: "Body".to_string(),
607 plain_offset: 20,
608 },
609 ];
610 let lines = ["# Intro", "x", "x", "x", "x", "## Body", "x"];
611 let mut s = session_with_headings(&lines, headings);
612 press(&mut s, 'T');
613 assert!(s.toc.is_some());
614 assert_eq!(s.toc.unwrap().selected, 0);
615 }
616
617 #[test]
619 fn toc_navigation_jumps_to_selected_heading() {
620 let headings = vec![
623 super::buffer::HeadingEntry {
624 level: 1,
625 text: "First".to_string(),
626 plain_offset: 0,
627 },
628 super::buffer::HeadingEntry {
629 level: 1,
630 text: "Second".to_string(),
631 plain_offset: 16,
632 },
633 ];
634 let lines = ["# First", "a", "b", "c", "d", "# Second", "e"];
635 let mut s = session_with_headings(&lines, headings);
636 press(&mut s, 'T');
637 press(&mut s, 'j');
638 let cmd = s
639 .decoder
640 .feed(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
641 let _ = super::dispatch_cmd(&mut s, cmd);
642 assert!(s.toc.is_none(), "TOC should close after activation");
643 assert_eq!(s.view.top, 3);
645 }
646
647 #[test]
649 fn bookmark_roundtrip_restores_view_top() {
650 let lines: Vec<&str> = (0..20).map(|_| "line").collect();
651 let mut s = session_with_headings(&lines, Vec::new());
652 s.view = View::new(80, 10);
654
655 s.view.top = 7;
656 press(&mut s, 'm');
657 press(&mut s, 'a');
658 s.view.top = 0;
660 press(&mut s, '\'');
661 press(&mut s, 'a');
662 assert_eq!(s.view.top, 7);
663 }
664}