pub mod buffer;
pub mod highlight;
pub mod keys;
pub mod search;
pub mod toc;
pub mod view;
use std::collections::HashMap;
use std::io::{self, BufWriter, Write};
use std::path::Path;
use anyhow::{Context, Result};
use crossterm::cursor::{Hide, Show};
use crossterm::event::{self, Event};
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, size, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::{execute, queue};
use pulldown_cmark::Parser;
use buffer::{build, HeadingRecorder, RenderedDoc};
use keys::{Command, Decoder, SearchDirection};
use search::{CaseMode, Direction, SearchState};
use toc::Toc;
use view::View;
use crate::args::CommonArgs;
use crate::resources::ResourceUrlHandler;
use crate::terminal::capabilities::TerminalCapabilities;
use crate::{read_input, Environment, Multiplexer, Settings, TerminalProgram, Theme};
#[derive(Debug, Clone, Default)]
pub struct MdlessOptions {
pub initial: Option<String>,
pub case_sensitive: bool,
pub regex: bool,
pub line_numbers: bool,
}
struct Session {
doc: RenderedDoc,
view: View,
decoder: Decoder,
search: Option<SearchState>,
direction: SearchDirection,
input: String,
status: Option<String>,
regex: bool,
case: CaseMode,
toc: Option<Toc>,
bookmarks: HashMap<char, usize>,
}
impl Session {
fn matches(&self) -> &[search::Match] {
self.search.as_ref().map_or(&[][..], SearchState::all)
}
fn current_match(&self) -> Option<&search::Match> {
self.search.as_ref().and_then(SearchState::current)
}
}
pub fn run(
filename: &str,
common: &CommonArgs,
opts: MdlessOptions,
resource_handler: &dyn ResourceUrlHandler,
) -> Result<i32> {
let doc = render_doc(filename, common, opts.line_numbers, resource_handler)?;
let (cols, rows) = size().unwrap_or((80, 24));
let mut session = Session {
doc,
view: View::new(cols, rows).with_line_numbers(opts.line_numbers),
decoder: Decoder::default(),
search: None,
direction: SearchDirection::Forward,
input: String::new(),
status: None,
regex: opts.regex,
case: if opts.case_sensitive {
CaseMode::Sensitive
} else {
CaseMode::Smart
},
toc: None,
bookmarks: HashMap::new(),
};
if let Some(initial) = opts.initial {
apply_search(&mut session, initial);
}
let _guard = TerminalGuard::enter()?;
let mut out = BufWriter::new(io::stdout());
draw(&session, &mut out)?;
loop {
match event::read()? {
Event::Key(key) if key.kind == event::KeyEventKind::Press => {
let cmd = session.decoder.feed(key);
if dispatch_cmd(&mut session, cmd) {
break;
}
draw(&session, &mut out)?;
}
Event::Resize(cols, rows) => {
session.view.resize(cols, rows, &session.doc);
draw(&session, &mut out)?;
}
_ => {}
}
}
Ok(0)
}
fn dispatch_cmd(s: &mut Session, cmd: Command) -> bool {
if s.toc.is_some() {
return dispatch_toc(s, cmd);
}
match cmd {
Command::Noop => false,
Command::Quit => true,
Command::Redraw => false,
Command::BeginSearch(dir) => {
s.direction = dir;
s.input.clear();
s.status = Some(prompt_for(dir).to_string());
false
}
Command::SearchChar(c) => {
s.input.push(c);
s.status = Some(format!("{}{}", prompt_for(s.direction), s.input));
false
}
Command::SearchBackspace => {
s.input.pop();
s.status = Some(format!("{}{}", prompt_for(s.direction), s.input));
false
}
Command::SearchCommit => {
let pattern = std::mem::take(&mut s.input);
if pattern.is_empty() {
s.status = None;
} else {
apply_search(s, pattern);
}
false
}
Command::SearchCancel | Command::ClearHighlights => {
s.search = None;
s.input.clear();
s.status = None;
false
}
Command::SearchNext => {
step_search(s, Direction::Forward);
false
}
Command::SearchPrev => {
step_search(s, Direction::Backward);
false
}
Command::NextHeading => {
jump_heading(s, Direction::Forward);
false
}
Command::PrevHeading => {
jump_heading(s, Direction::Backward);
false
}
Command::ToggleToc => {
s.toc = Some(Toc::new(&s.doc.headings));
s.status = None;
false
}
Command::SetBookmark(c) => {
s.bookmarks.insert(c, s.view.top);
s.status = Some(format!("bookmark {c} set at line {}", s.view.top + 1));
false
}
Command::JumpBookmark(c) => {
if let Some(&line) = s.bookmarks.get(&c) {
s.view.jump_to(line, &s.doc);
s.status = None;
} else {
s.status = Some(format!("no bookmark `{c}`"));
}
false
}
Command::ToggleLineNumbers => {
s.view.line_numbers = !s.view.line_numbers;
false
}
Command::TocActivate => false,
other => {
s.view.apply(other, &s.doc);
false
}
}
}
fn dispatch_toc(s: &mut Session, cmd: Command) -> bool {
let total = s.doc.headings.len();
match cmd {
Command::Quit => true,
Command::ToggleToc | Command::ClearHighlights | Command::SearchCancel => {
s.toc = None;
false
}
Command::ScrollDown(n) => {
if let Some(t) = s.toc.as_mut() {
t.step(isize::from(i16::try_from(n).unwrap_or(i16::MAX)), total);
}
false
}
Command::ScrollUp(n) => {
if let Some(t) = s.toc.as_mut() {
t.step(-isize::from(i16::try_from(n).unwrap_or(i16::MAX)), total);
}
false
}
Command::Home => {
if let Some(t) = s.toc.as_mut() {
t.selected = 0;
}
false
}
Command::End => {
if let Some(t) = s.toc.as_mut() {
t.selected = total.saturating_sub(1);
}
false
}
Command::TocActivate => {
let target = s.toc.as_ref().and_then(|t| s.doc.headings.get(t.selected));
if let Some(h) = target {
let line = s.doc.line_for_plain_offset(h.plain_offset);
s.view.scroll_to(line, &s.doc);
}
s.toc = None;
false
}
_ => false,
}
}
fn jump_heading(s: &mut Session, dir: Direction) {
let top = s.view.top;
let target = match dir {
Direction::Forward => s
.doc
.headings
.iter()
.map(|h| s.doc.line_for_plain_offset(h.plain_offset))
.find(|&line| line > top),
Direction::Backward => s
.doc
.headings
.iter()
.rev()
.map(|h| s.doc.line_for_plain_offset(h.plain_offset))
.find(|&line| line < top),
};
if let Some(line) = target {
s.view.scroll_to(line, &s.doc);
} else {
s.status = Some(match dir {
Direction::Forward => "no next heading".to_string(),
Direction::Backward => "no previous heading".to_string(),
});
}
}
fn prompt_for(dir: SearchDirection) -> &'static str {
match dir {
SearchDirection::Forward => "/",
SearchDirection::Backward => "?",
}
}
fn apply_search(s: &mut Session, pattern: String) {
let mut state = match SearchState::compile(&s.doc, &pattern, s.regex, s.case) {
Ok(state) => state,
Err(error) => {
s.status = Some(format!("{error}"));
return;
}
};
let initial = match s.direction {
SearchDirection::Forward => Direction::Forward,
SearchDirection::Backward => Direction::Backward,
};
let jump = state
.current()
.map(|m| m.line)
.or_else(|| state.step(initial).map(|(m, _)| m.line));
let total = state.len();
s.search = Some(state);
s.status = Some(if total == 0 {
format!("Pattern not found: {pattern}")
} else {
format!("{total} matches n/N:next/prev Esc:clear")
});
if let Some(line) = jump {
s.view.scroll_to(line, &s.doc);
}
}
fn step_search(s: &mut Session, dir: Direction) {
let Some(state) = s.search.as_mut() else {
return;
};
if let Some((m, wrapped)) = state.step(dir) {
if wrapped {
s.status = Some("search wrapped".to_string());
}
s.view.scroll_to(m.line, &s.doc);
}
}
fn draw<W: Write>(s: &Session, out: &mut W) -> io::Result<()> {
match s.toc.as_ref() {
Some(toc) => s.view.draw_toc(out, &s.doc.headings, toc),
None => s.view.draw(
out,
&s.doc,
s.matches(),
s.current_match(),
s.status.as_deref(),
),
}
}
const MAX_RENDER_COLS: u16 = 120;
fn render_doc(
filename: &str,
common: &CommonArgs,
line_numbers: bool,
resource_handler: &dyn ResourceUrlHandler,
) -> Result<RenderedDoc> {
let (base_dir, input) = read_input(filename)?;
let parser = Parser::new_ext(&input, crate::markdown_options());
let env =
Environment::for_local_directory(&base_dir).context("build environment for mdless")?;
let (cols, _rows) = size().unwrap_or((80, 24));
let reserved = if line_numbers { view::GUTTER } else { 2 };
let columns = common
.columns
.unwrap_or_else(|| cols.saturating_sub(reserved).clamp(20, MAX_RENDER_COLS));
let terminal_size = crate::TerminalSize::default().with_max_columns(columns);
let syntax_set = syntect::parsing::SyntaxSet::load_defaults_newlines();
let settings = Settings {
terminal_capabilities: ansi_without_images(),
terminal_size,
multiplexer: Multiplexer::None,
syntax_set: &syntax_set,
theme: Theme::default(),
wrap_code: common.wrap_code,
};
let mut styled = Vec::with_capacity(input.len() * 2);
let mut recorder = HeadingRecorder::default();
crate::push_tty_with_observer(
&settings,
&env,
resource_handler,
&mut styled,
parser,
&mut recorder,
)
.with_context(|| format!("rendering {}", Path::new(filename).display()))?;
Ok(build(styled, recorder.finish()))
}
fn ansi_without_images() -> TerminalCapabilities {
let mut caps = TerminalProgram::Ansi.capabilities();
caps.image = None;
caps
}
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> Result<Self> {
enable_raw_mode().context("enable raw mode")?;
execute!(io::stdout(), EnterAlternateScreen, Hide).context("enter alternate screen")?;
install_panic_hook();
Ok(Self)
}
}
fn install_panic_hook() {
use std::sync::Once;
static HOOK: Once = Once::new();
HOOK.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let mut out = io::stdout();
let _ = queue!(out, Show, LeaveAlternateScreen);
let _ = out.flush();
let _ = disable_raw_mode();
previous(info);
}));
});
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let mut out = io::stdout();
let _ = queue!(out, Show, LeaveAlternateScreen);
let _ = out.flush();
let _ = disable_raw_mode();
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::buffer::build;
use super::keys::{Command, Decoder};
use super::view::View;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
fn doc(lines: &[&str]) -> super::RenderedDoc {
let styled = lines
.iter()
.flat_map(|l| l.as_bytes().iter().copied().chain(std::iter::once(b'\n')))
.collect();
build(styled, Vec::new())
}
#[test]
fn scripted_keystrokes_drive_viewport() {
let d = doc(&[
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
]);
let mut v = View::new(80, 4); let mut dec = Decoder::default();
let script = [
(KeyCode::Char('j'), 1),
(KeyCode::Char('j'), 2),
(KeyCode::Char(' '), 5),
(KeyCode::Char('k'), 4),
(KeyCode::Char('G'), 7),
(KeyCode::Char('g'), 7), (KeyCode::Char('g'), 0), ];
for (code, expected_top) in script {
let cmd = dec.feed(KeyEvent::new(code, KeyModifiers::NONE));
v.apply(cmd, &d);
assert_eq!(v.top, expected_top, "after {code:?}");
}
let quit_cmd = dec.feed(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(v.apply(quit_cmd, &d));
}
#[test]
fn resize_clamp_preserves_visibility() {
let d = doc(&["a"; 20]);
let mut v = View::new(80, 10);
v.apply(Command::End, &d); v.resize(80, 80, &d); assert_eq!(v.top, 0);
}
fn session_with_headings(
lines: &[&str],
headings: Vec<super::buffer::HeadingEntry>,
) -> super::Session {
let styled: Vec<u8> = lines
.iter()
.flat_map(|l| l.as_bytes().iter().copied().chain(std::iter::once(b'\n')))
.collect();
let doc = build(styled, headings);
super::Session {
doc,
view: View::new(80, 5),
decoder: Decoder::default(),
search: None,
direction: super::SearchDirection::Forward,
input: String::new(),
status: None,
regex: false,
case: super::CaseMode::Smart,
toc: None,
bookmarks: HashMap::new(),
}
}
fn press(s: &mut super::Session, c: char) {
let cmd = s
.decoder
.feed(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
let _ = super::dispatch_cmd(s, cmd);
}
#[test]
fn double_bracket_jumps_to_next_heading() {
let lines = (0..20)
.map(|i| if i == 5 { "## Sub" } else { "body" })
.collect::<Vec<_>>();
let offset = (0..5).map(|_| "body".len() + 1).sum::<usize>();
let headings = vec![super::buffer::HeadingEntry {
level: 2,
text: "Sub".to_string(),
plain_offset: offset,
}];
let mut s = session_with_headings(&lines, headings);
super::jump_heading(&mut s, super::Direction::Forward);
assert_eq!(s.view.top, 3);
}
#[test]
fn t_opens_toc_modal() {
let headings = vec![
super::buffer::HeadingEntry {
level: 1,
text: "Intro".to_string(),
plain_offset: 0,
},
super::buffer::HeadingEntry {
level: 2,
text: "Body".to_string(),
plain_offset: 20,
},
];
let lines = ["# Intro", "x", "x", "x", "x", "## Body", "x"];
let mut s = session_with_headings(&lines, headings);
press(&mut s, 'T');
assert!(s.toc.is_some());
assert_eq!(s.toc.unwrap().selected, 0);
}
#[test]
fn toc_navigation_jumps_to_selected_heading() {
let headings = vec![
super::buffer::HeadingEntry {
level: 1,
text: "First".to_string(),
plain_offset: 0,
},
super::buffer::HeadingEntry {
level: 1,
text: "Second".to_string(),
plain_offset: 16,
},
];
let lines = ["# First", "a", "b", "c", "d", "# Second", "e"];
let mut s = session_with_headings(&lines, headings);
press(&mut s, 'T');
press(&mut s, 'j');
let cmd = s
.decoder
.feed(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let _ = super::dispatch_cmd(&mut s, cmd);
assert!(s.toc.is_none(), "TOC should close after activation");
assert_eq!(s.view.top, 3);
}
#[test]
fn bookmark_roundtrip_restores_view_top() {
let lines: Vec<&str> = (0..20).map(|_| "line").collect();
let mut s = session_with_headings(&lines, Vec::new());
s.view = View::new(80, 10);
s.view.top = 7;
press(&mut s, 'm');
press(&mut s, 'a');
s.view.top = 0;
press(&mut s, '\'');
press(&mut s, 'a');
assert_eq!(s.view.top, 7);
}
}