use std::collections::HashMap;
use std::fs;
use std::io::{self, Write, stdout};
use std::time::Duration;
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute, queue,
terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
};
use regex::{Regex, RegexBuilder};
use unicode_width::UnicodeWidthChar;
use crate::highlight::{Color, Line, highlight_file};
pub struct Source {
name: String,
kind: SourceKind,
}
enum SourceKind {
File(String),
Memory(Vec<Line>),
}
impl Source {
pub fn file(name: impl Into<String>, path: impl Into<String>) -> Self {
Source {
name: name.into(),
kind: SourceKind::File(path.into()),
}
}
pub fn memory(name: impl Into<String>, lines: Vec<Line>) -> Self {
Source {
name: name.into(),
kind: SourceKind::Memory(lines),
}
}
}
pub enum StartAction {
None,
Line(usize), End,
Search(String), }
pub fn parse_plus(rest: &str) -> Option<StartAction> {
if rest == "G" {
Some(StartAction::End)
} else if let Some(pat) = rest.strip_prefix('/') {
(!pat.is_empty()).then(|| StartAction::Search(pat.to_string()))
} else if let Ok(n) = rest.parse::<usize>() {
Some(StartAction::Line(n.saturating_sub(1)))
} else {
None
}
}
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> io::Result<Self> {
terminal::enable_raw_mode()?;
execute!(stdout(), EnterAlternateScreen, cursor::Hide)?;
Ok(Self)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = execute!(stdout(), cursor::Show, LeaveAlternateScreen);
let _ = terminal::disable_raw_mode();
}
}
pub fn run(
sources: Vec<Source>,
wrap: bool,
numbers: bool,
start: StartAction,
) -> io::Result<()> {
let _guard = TerminalGuard::enter()?;
let mut pager = Pager::new(sources, wrap, numbers, start)?;
pager.run()
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SearchDir {
Forward,
Backward,
}
struct SearchState {
re: Regex,
dir: SearchDir,
}
struct Filter {
re: Regex,
negate: bool,
}
fn smartcase_regex(pat: &str) -> Result<Regex, regex::Error> {
let insensitive = pat.chars().all(|c| !c.is_uppercase());
RegexBuilder::new(pat).case_insensitive(insensitive).build()
}
enum Mode {
Normal,
SearchInput { dir: SearchDir, buffer: String },
FilterInput { buffer: String },
Help,
}
enum PromptKey {
Submit(String),
Cancel,
Keep(String),
}
fn prompt_key(mut buffer: String, k: KeyEvent) -> PromptKey {
match (k.code, k.modifiers) {
(KeyCode::Enter, _) => PromptKey::Submit(buffer),
(KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => PromptKey::Cancel,
(KeyCode::Backspace, _) => match buffer.pop() {
None => PromptKey::Cancel,
Some(_) => PromptKey::Keep(buffer),
},
(KeyCode::Char(c), m) if m == KeyModifiers::NONE || m == KeyModifiers::SHIFT => {
buffer.push(c);
PromptKey::Keep(buffer)
}
_ => PromptKey::Keep(buffer),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Pending {
None,
Z, Dash, Mark, Quote, Colon, }
struct Pager {
sources: Vec<Source>,
index: usize,
name: String,
lines: Vec<Line>,
top: usize,
sub: usize,
left: usize,
wrap: bool,
numbers: bool,
following: bool,
follow_len: u64,
count: Option<usize>,
pending: Pending,
marks: HashMap<char, (usize, usize)>,
filters: Vec<Filter>,
view: Option<Vec<usize>>,
start: StartAction,
prev_pos: Option<(usize, usize)>,
mode: Mode,
search: Option<SearchState>,
message: Option<String>,
cols: usize,
rows: usize,
}
impl Pager {
fn new(
sources: Vec<Source>,
wrap: bool,
numbers: bool,
start: StartAction,
) -> io::Result<Self> {
let mut pager = Self {
sources,
index: 0,
name: String::new(),
lines: Vec::new(),
top: 0,
sub: 0,
left: 0,
wrap,
numbers,
following: false,
follow_len: 0,
count: None,
pending: Pending::None,
marks: HashMap::new(),
filters: Vec::new(),
view: None,
start,
prev_pos: None,
mode: Mode::Normal,
search: None,
message: None,
cols: 80,
rows: 24,
};
pager.load(0)?;
Ok(pager)
}
fn load(&mut self, index: usize) -> io::Result<()> {
let lines = match &self.sources[index].kind {
SourceKind::Memory(l) => l.to_vec(),
SourceKind::File(path) => {
let content = fs::read_to_string(path)?;
highlight_file(&content, path)
}
};
self.index = index;
self.name = self.sources[index].name.clone();
self.lines = lines;
self.top = 0;
self.sub = 0;
self.left = 0;
self.marks.clear();
self.filters.clear();
self.view = None;
self.prev_pos = None;
self.following = false;
Ok(())
}
fn switch_file(&mut self, index: usize) {
match self.load(index) {
Err(e) => self.message = Some(format!("{}: {}", self.sources[index].name, e)),
Ok(()) => {
self.message =
Some(format!("{} (file {}/{})", self.name, index + 1, self.sources.len()))
}
}
}
fn next_file(&mut self) {
if self.index + 1 < self.sources.len() {
self.switch_file(self.index + 1);
} else {
self.message = Some("No next file".to_string());
}
}
fn prev_file(&mut self) {
if self.index > 0 {
self.switch_file(self.index - 1);
} else {
self.message = Some("No previous file".to_string());
}
}
fn body_rows(&self) -> usize {
self.rows.saturating_sub(1)
}
fn gutter_width(&self) -> usize {
gutter_width(self.lines.len(), self.numbers)
}
fn content_cols(&self) -> usize {
self.cols.saturating_sub(self.gutter_width()).max(1)
}
fn line_height(&self, idx: usize) -> usize {
line_height_of(&self.lines[idx], self.content_cols(), self.wrap)
}
fn end_pos(&self) -> (usize, usize) {
match last_visible(self.view.as_deref(), self.lines.len()) {
None => (0, 0),
Some(last) => (last, self.line_height(last).saturating_sub(1)),
}
}
fn max_scroll(&self) -> (usize, usize) {
let body = self.body_rows().max(1);
let (t, s) = self.end_pos();
self.pos_up(t, s, body - 1)
}
fn pos_down(&self, mut top: usize, mut sub: usize, n: usize) -> (usize, usize) {
let view = self.view.as_deref();
for _ in 0..n {
if top >= self.lines.len() {
break;
}
if sub + 1 < self.line_height(top) {
sub += 1;
} else if let Some(next) = next_visible(view, self.lines.len(), top) {
top = next;
sub = 0;
} else {
break;
}
}
(top, sub)
}
fn pos_up(&self, mut top: usize, mut sub: usize, n: usize) -> (usize, usize) {
let view = self.view.as_deref();
for _ in 0..n {
if sub > 0 {
sub -= 1;
} else if let Some(prev) = prev_visible(view, top) {
top = prev;
sub = self.line_height(prev).saturating_sub(1);
} else {
break;
}
}
(top, sub)
}
fn scroll_down(&mut self, n: usize) {
let p = self.pos_down(self.top, self.sub, n).min(self.max_scroll());
(self.top, self.sub) = p;
}
fn scroll_up(&mut self, n: usize) {
(self.top, self.sub) = self.pos_up(self.top, self.sub, n);
}
fn clamp_view(&self, top: usize, sub: usize) -> (usize, usize) {
if self.lines.is_empty() {
return (0, 0);
}
let top = snap_visible(self.view.as_deref(), self.lines.len(), top);
clamp_pos(&self.lines, self.content_cols(), self.wrap, top, sub)
}
fn move_to(&mut self, top: usize, sub: usize) {
(self.top, self.sub) = self.clamp_view(top, sub).min(self.max_scroll());
}
fn apply_filters(&mut self) {
self.view = build_view(&self.lines, &self.filters);
self.move_to(self.top, 0);
}
fn goto_line(&mut self, line: usize) {
self.move_to(line, 0);
}
fn goto_end(&mut self) {
(self.top, self.sub) = self.max_scroll();
}
fn jump_to(&mut self, pos: (usize, usize)) {
self.prev_pos = Some((self.top, self.sub));
self.move_to(pos.0, pos.1);
}
fn goto_mark(&mut self, c: char) {
let target = if c == '\'' {
self.prev_pos
} else {
self.marks.get(&c).copied()
};
match target {
Some(pos) => self.jump_to(pos),
None => self.message = Some("Mark not set".to_string()),
}
}
fn bottom_line(&self) -> usize {
let body = self.body_rows().max(1);
self.pos_down(self.top, self.sub, body - 1).0
}
fn at_end(&self) -> bool {
(self.top, self.sub) >= self.max_scroll()
}
fn toggle_wrap(&mut self) {
self.wrap = !self.wrap;
self.left = 0;
self.move_to(self.top, 0);
self.message = Some(
if self.wrap {
"Wrap long lines"
} else {
"Chop long lines"
}
.to_string(),
);
}
fn toggle_numbers(&mut self) {
self.numbers = !self.numbers;
self.move_to(self.top, self.sub);
self.message = Some(
if self.numbers {
"Line numbers"
} else {
"No line numbers"
}
.to_string(),
);
}
fn refresh_size(&mut self) -> io::Result<()> {
let (c, r) = terminal::size()?;
self.cols = c as usize;
self.rows = r.max(2) as usize;
self.move_to(self.top, self.sub);
Ok(())
}
fn apply_start(&mut self) {
match std::mem::replace(&mut self.start, StartAction::None) {
StartAction::None => {}
StartAction::Line(n) => self.goto_line(n),
StartAction::End => self.goto_end(),
StartAction::Search(pat) => self.set_search(&pat, SearchDir::Forward),
}
}
fn run(&mut self) -> io::Result<()> {
self.refresh_size()?;
self.apply_start();
self.draw()?;
loop {
if self.following {
if event::poll(Duration::from_millis(300))? {
match event::read()? {
Event::Key(k) if k.kind != KeyEventKind::Release => {
self.following = false;
self.message = Some("Stopped following".to_string());
}
Event::Resize(..) => {}
_ => continue,
}
} else if !self.poll_growth()? {
continue;
}
} else {
match event::read()? {
Event::Key(k) if k.kind != KeyEventKind::Release => {
if self.handle_key(k) {
break;
}
}
Event::Resize(..) => {}
_ => continue,
}
}
self.refresh_size()?;
self.draw()?;
}
Ok(())
}
fn start_follow(&mut self) {
let path = match &self.sources[self.index].kind {
SourceKind::File(p) => p.clone(),
SourceKind::Memory(_) => {
self.message = Some("Cannot follow stdin".to_string());
return;
}
};
self.follow_len = fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
self.following = true;
self.filters.clear();
self.apply_filters();
self.goto_end();
self.message = Some("Waiting for data... (any key to stop)".to_string());
}
fn poll_growth(&mut self) -> io::Result<bool> {
let path = match &self.sources[self.index].kind {
SourceKind::File(p) => p.clone(),
SourceKind::Memory(_) => return Ok(false),
};
let len = match fs::metadata(&path) {
Ok(m) => m.len(),
Err(_) => return Ok(false),
};
if len == self.follow_len {
return Ok(false);
}
match fs::read_to_string(&path) {
Ok(content) => {
self.lines = highlight_file(&content, &path);
self.follow_len = len;
self.goto_end();
Ok(true)
}
Err(_) => {
self.following = false;
self.message = Some("File no longer readable".to_string());
Ok(true)
}
}
}
fn handle_key(&mut self, k: KeyEvent) -> bool {
if !matches!(self.mode, Mode::SearchInput { .. } | Mode::FilterInput { .. }) {
self.message = None;
}
match self.mode {
Mode::Normal => self.handle_normal(k),
Mode::SearchInput { .. } => {
self.handle_search_input(k);
false
}
Mode::FilterInput { .. } => {
self.handle_filter_input(k);
false
}
Mode::Help => {
self.handle_help(k);
false
}
}
}
fn handle_normal(&mut self, k: KeyEvent) -> bool {
let body = self.body_rows().max(1);
let count = self.count;
let pending = std::mem::replace(&mut self.pending, Pending::None);
match (pending, k.code) {
(Pending::Dash, KeyCode::Char('S')) => {
self.toggle_wrap();
self.count = None;
return false;
}
(Pending::Dash, KeyCode::Char('N')) => {
self.toggle_numbers();
self.count = None;
return false;
}
(Pending::Mark, KeyCode::Char(c)) if c.is_ascii_alphanumeric() => {
self.marks.insert(c, (self.top, self.sub));
self.count = None;
return false;
}
(Pending::Quote, KeyCode::Char(c)) => {
self.goto_mark(c);
self.count = None;
return false;
}
(Pending::Colon, KeyCode::Char('n')) => {
self.next_file();
self.count = None;
return false;
}
(Pending::Colon, KeyCode::Char('p')) => {
self.prev_file();
self.count = None;
return false;
}
(Pending::Colon, KeyCode::Char('f')) => {
self.message = Some(self.info_string());
self.count = None;
return false;
}
_ => {}
}
if let KeyCode::Char(c @ '0'..='9') = k.code
&& (k.modifiers == KeyModifiers::NONE || k.modifiers == KeyModifiers::SHIFT)
{
let d = (c as u8 - b'0') as usize;
if !(d == 0 && self.count.is_none()) {
self.count = Some(
self.count
.unwrap_or(0)
.saturating_mul(10)
.saturating_add(d),
);
}
return false;
}
let mut consume = true;
match (k.code, k.modifiers) {
(KeyCode::Char('q'), KeyModifiers::NONE)
| (KeyCode::Char('Q'), KeyModifiers::SHIFT) => return true,
(KeyCode::Char('c'), KeyModifiers::CONTROL) => return true,
(KeyCode::Char('Z'), _) => {
if pending == Pending::Z {
return true;
}
self.pending = Pending::Z;
consume = false;
}
(KeyCode::Char('-'), _) => {
self.pending = Pending::Dash;
consume = false;
}
(KeyCode::Char('m'), KeyModifiers::NONE) => {
self.pending = Pending::Mark;
consume = false;
}
(KeyCode::Char('\''), _) => {
self.pending = Pending::Quote;
consume = false;
}
(KeyCode::Char(':'), _) => {
self.pending = Pending::Colon;
consume = false;
}
(KeyCode::Char('F'), _) => self.start_follow(),
(KeyCode::Char('j'), KeyModifiers::NONE)
| (KeyCode::Down, _)
| (KeyCode::Enter, _)
| (KeyCode::Char('e'), KeyModifiers::CONTROL)
| (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
self.scroll_down(count.unwrap_or(1));
}
(KeyCode::Char('k'), KeyModifiers::NONE)
| (KeyCode::Up, _)
| (KeyCode::Char('y'), KeyModifiers::CONTROL)
| (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
self.scroll_up(count.unwrap_or(1));
}
(KeyCode::Char(' '), _)
| (KeyCode::Char('f'), KeyModifiers::NONE)
| (KeyCode::Char('f'), KeyModifiers::CONTROL)
| (KeyCode::Char('v'), KeyModifiers::CONTROL)
| (KeyCode::PageDown, _) => {
self.scroll_down(count.unwrap_or(body));
}
(KeyCode::Char('b'), KeyModifiers::NONE)
| (KeyCode::Char('b'), KeyModifiers::CONTROL)
| (KeyCode::PageUp, _) => {
self.scroll_up(count.unwrap_or(body));
}
(KeyCode::Char('d'), KeyModifiers::NONE)
| (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
self.scroll_down(count.unwrap_or(body / 2).max(1));
}
(KeyCode::Char('u'), KeyModifiers::NONE)
| (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
self.scroll_up(count.unwrap_or(body / 2).max(1));
}
(KeyCode::Char('g'), KeyModifiers::NONE)
| (KeyCode::Char('<'), _)
| (KeyCode::Home, _) => {
self.goto_line(count.map(|n| n.saturating_sub(1)).unwrap_or(0));
}
(KeyCode::Char('G'), _) | (KeyCode::Char('>'), _) | (KeyCode::End, _) => {
match count {
Some(n) => self.goto_line(n.saturating_sub(1)),
None => self.goto_end(),
}
}
(KeyCode::Char('p'), KeyModifiers::NONE) | (KeyCode::Char('%'), _) => {
let pct = count.unwrap_or(0).min(100);
let target = self.lines.len().saturating_mul(pct) / 100;
self.goto_line(target);
}
(KeyCode::Right, _) => {
if !self.wrap {
self.left = self.left.saturating_add(self.cols.max(2) / 2);
}
}
(KeyCode::Left, _) => {
if !self.wrap {
self.left = self.left.saturating_sub(self.cols.max(2) / 2);
}
}
(KeyCode::Char('/'), _) => {
self.mode = Mode::SearchInput {
dir: SearchDir::Forward,
buffer: String::new(),
};
consume = false;
}
(KeyCode::Char('?'), _) => {
self.mode = Mode::SearchInput {
dir: SearchDir::Backward,
buffer: String::new(),
};
consume = false;
}
(KeyCode::Char('n'), KeyModifiers::NONE) => self.repeat_search(false),
(KeyCode::Char('N'), _) => self.repeat_search(true),
(KeyCode::Char('&'), _) => {
self.mode = Mode::FilterInput {
buffer: String::new(),
};
consume = false;
}
(KeyCode::Char('r'), _)
| (KeyCode::Char('R'), _)
| (KeyCode::Char('l'), KeyModifiers::CONTROL) => {}
(KeyCode::Char('='), _) | (KeyCode::Char('g'), KeyModifiers::CONTROL) => {
self.message = Some(self.info_string());
}
(KeyCode::Char('h'), KeyModifiers::NONE) | (KeyCode::Char('H'), _) => {
self.mode = Mode::Help;
consume = false;
}
_ => {
consume = false;
}
}
if consume {
self.count = None;
}
false
}
fn handle_search_input(&mut self, k: KeyEvent) {
let (dir, buffer) = match std::mem::replace(&mut self.mode, Mode::Normal) {
Mode::SearchInput { dir, buffer } => (dir, buffer),
other => {
self.mode = other;
return;
}
};
match prompt_key(buffer, k) {
PromptKey::Submit(buffer) => {
if !buffer.is_empty() {
self.set_search(&buffer, dir);
}
}
PromptKey::Cancel => self.message = None,
PromptKey::Keep(buffer) => self.mode = Mode::SearchInput { dir, buffer },
}
}
fn set_search(&mut self, pat: &str, dir: SearchDir) {
match smartcase_regex(pat) {
Ok(re) => {
self.search = Some(SearchState { re, dir });
self.message = None;
self.do_search(dir, false);
}
Err(e) => self.message = Some(format!("Invalid regex: {}", e)),
}
}
fn handle_filter_input(&mut self, k: KeyEvent) {
let buffer = match std::mem::replace(&mut self.mode, Mode::Normal) {
Mode::FilterInput { buffer } => buffer,
other => {
self.mode = other;
return;
}
};
match prompt_key(buffer, k) {
PromptKey::Submit(buffer) => self.submit_filter(&buffer),
PromptKey::Cancel => self.message = None,
PromptKey::Keep(buffer) => self.mode = Mode::FilterInput { buffer },
}
}
fn submit_filter(&mut self, buffer: &str) {
if buffer.is_empty() {
self.filters.clear();
self.apply_filters();
self.message = Some("Filter cleared".to_string());
return;
}
let (negate, pat) = match buffer.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, buffer),
};
match smartcase_regex(pat) {
Ok(re) => {
self.filters.push(Filter { re, negate });
self.apply_filters();
self.message = None;
}
Err(e) => self.message = Some(format!("Invalid regex: {}", e)),
}
}
fn handle_help(&mut self, k: KeyEvent) {
if matches!(
k.code,
KeyCode::Char('q') | KeyCode::Esc | KeyCode::Char('h') | KeyCode::Char('H')
) || (k.code == KeyCode::Char('c') && k.modifiers == KeyModifiers::CONTROL)
{
self.mode = Mode::Normal;
}
}
fn repeat_search(&mut self, reverse: bool) {
let dir = match (self.search.as_ref(), reverse) {
(Some(s), false) => s.dir,
(Some(s), true) => match s.dir {
SearchDir::Forward => SearchDir::Backward,
SearchDir::Backward => SearchDir::Forward,
},
(None, _) => {
self.message = Some("No previous search".to_string());
return;
}
};
self.do_search(dir, true);
}
fn do_search(&mut self, dir: SearchDir, skip_current: bool) {
let Some(state) = self.search.as_ref() else {
return;
};
let view = self.view.as_deref();
let n = self.lines.len();
if first_visible(view, n).is_none() {
self.message = Some("Pattern not found".to_string());
return;
}
let mut cur = if skip_current {
match dir {
SearchDir::Forward => next_visible(view, n, self.top),
SearchDir::Backward => prev_visible(view, self.top),
}
} else {
Some(self.top)
};
let mut found = None;
while let Some(i) = cur {
if state.re.is_match(&line_plain(&self.lines[i])) {
found = Some(i);
break;
}
cur = match dir {
SearchDir::Forward => next_visible(view, n, i),
SearchDir::Backward => prev_visible(view, i),
};
}
match found {
Some(i) => self.goto_line(i),
None => self.message = Some("Pattern not found".to_string()),
}
}
fn info_string(&self) -> String {
let total = self.lines.len();
let last = (self.bottom_line() + 1).min(total);
let pct = (last * 100).checked_div(total).unwrap_or(100).min(100);
let base = format!(
"{} lines {}-{}/{} {}%",
self.name,
self.top + 1,
last.max(self.top + 1),
total,
pct
);
match &self.view {
Some(v) => format!("{} [filtered {}/{}]", base, v.len(), total),
None => base,
}
}
fn draw(&self) -> io::Result<()> {
let mut out = stdout().lock();
let body = self.body_rows();
if matches!(self.mode, Mode::Help) {
self.draw_help(&mut out)?;
return out.flush();
}
let cols = self.content_cols();
let digits = self.gutter_width().saturating_sub(1);
let mut top = self.top;
if first_visible(self.view.as_deref(), self.lines.len()).is_none() {
top = self.lines.len();
}
let mut sub = self.sub;
let mut ranges = if self.wrap && top < self.lines.len() {
wrap_ranges(&self.lines[top], cols)
} else {
Vec::new()
};
for row in 0..body {
queue!(
out,
cursor::MoveTo(0, row as u16),
Clear(ClearType::CurrentLine)
)?;
if top < self.lines.len() {
if self.numbers {
out.write_all(gutter(top + 1, sub == 0, digits).as_bytes())?;
}
if self.wrap {
let range = ranges[sub.min(ranges.len() - 1)];
let rendered =
render_segment(&self.lines[top], range, self.search.as_ref());
out.write_all(rendered.as_bytes())?;
if sub + 1 < ranges.len() {
sub += 1;
} else {
match next_visible(self.view.as_deref(), self.lines.len(), top) {
Some(next) => {
top = next;
sub = 0;
ranges = wrap_ranges(&self.lines[top], cols);
}
None => top = self.lines.len(),
}
}
} else {
let rendered =
render_line(&self.lines[top], self.left, cols, self.search.as_ref());
out.write_all(rendered.as_bytes())?;
top = next_visible(self.view.as_deref(), self.lines.len(), top)
.unwrap_or(self.lines.len());
}
} else {
out.write_all(b"\x1b[38;2;90;90;90m~\x1b[0m")?;
}
}
queue!(
out,
cursor::MoveTo(0, body as u16),
Clear(ClearType::CurrentLine)
)?;
match &self.mode {
Mode::SearchInput { dir, buffer } => {
let prompt = if *dir == SearchDir::Forward { '/' } else { '?' };
return self.draw_prompt(&mut out, prompt, buffer, body);
}
Mode::FilterInput { buffer } => {
return self.draw_prompt(&mut out, '&', buffer, body);
}
Mode::Help => unreachable!(),
Mode::Normal => {
queue!(out, cursor::Hide)?;
if let Some(msg) = &self.message {
let mut s = msg.clone();
truncate_pad(&mut s, self.cols);
out.write_all(s.as_bytes())?;
} else if let Some(c) = self.count {
out.write_all(format!(":{}", c).as_bytes())?;
} else {
let mut s = self.status_string();
truncate_pad(&mut s, self.cols);
out.write_all(b"\x1b[7m")?;
out.write_all(s.as_bytes())?;
out.write_all(b"\x1b[0m")?;
}
}
}
out.flush()
}
fn draw_prompt<W: Write>(
&self,
out: &mut W,
prompt: char,
buffer: &str,
row: usize,
) -> io::Result<()> {
out.write_all(format!("{}{}", prompt, buffer).as_bytes())?;
queue!(out, cursor::Show)?;
queue!(
out,
cursor::MoveTo((buffer.len() + 1).min(self.cols) as u16, row as u16)
)?;
out.flush()
}
fn status_string(&self) -> String {
let files = if self.sources.len() > 1 {
format!(" (file {}/{})", self.index + 1, self.sources.len())
} else {
String::new()
};
let total = self.lines.len();
if total == 0 {
return format!(" {}{} (empty)", self.name, files);
}
let last = (self.bottom_line() + 1).min(total);
let pct = (last * 100 / total).min(100);
if self.at_end() {
format!(" {}{} (END) {}/{} {}%", self.name, files, last, total, pct)
} else {
format!(" {}{} {}/{} {}%", self.name, files, last, total, pct)
}
}
fn draw_help<W: Write>(&self, out: &mut W) -> io::Result<()> {
queue!(out, cursor::MoveTo(0, 0), Clear(ClearType::All))?;
let body = self.body_rows();
for (i, line) in HELP_TEXT.lines().enumerate() {
if i >= body {
break;
}
queue!(out, cursor::MoveTo(0, i as u16))?;
out.write_all(line.as_bytes())?;
}
queue!(
out,
cursor::MoveTo(0, body as u16),
Clear(ClearType::CurrentLine)
)?;
let mut footer = String::from(" HELP -- press q, h, or Esc to return ");
truncate_pad(&mut footer, self.cols);
out.write_all(b"\x1b[7m")?;
out.write_all(footer.as_bytes())?;
out.write_all(b"\x1b[0m")?;
Ok(())
}
}
const HELP_TEXT: &str = "\
cless -- a colorised less
MOVEMENT
j DOWN ENTER ^E ^N forward one line ([N] lines)
k UP ^Y ^P backward one line ([N] lines)
SPACE f ^F ^V PgDn forward one window
b ^B PgUp backward one window
d ^D forward half-window
u ^U backward half-window
g < HOME go to first line ([N]g -> line N)
G > END go to last line ([N]G -> line N)
p % go to [N] percent into file
LEFT RIGHT half-screen horizontal scroll (chop mode)
MARKS
m<letter> set mark at current position
'<letter> jump to mark
'' jump to previous position
FILES
:n next file
:p previous file
F follow file (tail -f); any key stops
SEARCHING
/pattern search forward
?pattern search backward
n repeat last search
N repeat in reverse direction
(smart-case: lower-only -> ignore case)
&pattern show only matching lines (&!pat excludes,
& alone clears; multiple & combine)
OTHER
-S toggle wrap / chop long lines
-N toggle line numbers
= ^G :f show current file info
r R ^L repaint screen
h H this help screen
q Q ZZ ^C quit
";
fn wrap_ranges(line: &Line, cols: usize) -> Vec<(usize, usize)> {
let cols = cols.max(1);
let mut ranges = Vec::new();
let mut seg_start = 0usize;
let mut byte_pos = 0usize;
let mut col = 0usize;
for (_, text) in &line.spans {
for ch in text.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if cw > 0 && col + cw > cols && col > 0 {
ranges.push((seg_start, byte_pos));
seg_start = byte_pos;
col = 0;
}
col += cw;
byte_pos += ch.len_utf8();
}
}
ranges.push((seg_start, byte_pos));
ranges
}
fn digit_count(n: usize) -> usize {
let mut n = n;
let mut d = 1;
while n >= 10 {
n /= 10;
d += 1;
}
d
}
fn gutter_width(n_lines: usize, numbers: bool) -> usize {
if numbers {
digit_count(n_lines.max(1)) + 1
} else {
0
}
}
fn gutter(line_no: usize, first_row: bool, digits: usize) -> String {
if first_row {
format!("\x1b[38;2;90;90;90m{:>w$} \x1b[0m", line_no, w = digits)
} else {
" ".repeat(digits + 1)
}
}
fn line_height_of(line: &Line, cols: usize, wrap: bool) -> usize {
if wrap {
wrap_ranges(line, cols).len()
} else {
1
}
}
fn clamp_pos(
lines: &[Line],
cols: usize,
wrap: bool,
mut top: usize,
mut sub: usize,
) -> (usize, usize) {
if lines.is_empty() {
return (0, 0);
}
if top >= lines.len() {
top = lines.len() - 1;
}
let h = line_height_of(&lines[top], cols, wrap);
if sub >= h {
sub = h - 1;
}
(top, sub)
}
fn line_plain(line: &Line) -> String {
let mut s = String::new();
for (_, t) in &line.spans {
s.push_str(t);
}
s
}
fn build_view(lines: &[Line], filters: &[Filter]) -> Option<Vec<usize>> {
if filters.is_empty() {
return None;
}
let view = (0..lines.len())
.filter(|&i| {
let text = line_plain(&lines[i]);
filters.iter().all(|f| f.re.is_match(&text) != f.negate)
})
.collect();
Some(view)
}
fn first_visible(view: Option<&[usize]>, n_lines: usize) -> Option<usize> {
match view {
None => (n_lines > 0).then_some(0),
Some(v) => v.first().copied(),
}
}
fn last_visible(view: Option<&[usize]>, n_lines: usize) -> Option<usize> {
match view {
None => n_lines.checked_sub(1),
Some(v) => v.last().copied(),
}
}
fn next_visible(view: Option<&[usize]>, n_lines: usize, line: usize) -> Option<usize> {
match view {
None => (line + 1 < n_lines).then_some(line + 1),
Some(v) => v.get(v.partition_point(|&x| x <= line)).copied(),
}
}
fn prev_visible(view: Option<&[usize]>, line: usize) -> Option<usize> {
match view {
None => line.checked_sub(1),
Some(v) => {
let p = v.partition_point(|&x| x < line);
(p > 0).then(|| v[p - 1])
}
}
}
fn snap_visible(view: Option<&[usize]>, n_lines: usize, line: usize) -> usize {
match view {
None => line.min(n_lines.saturating_sub(1)),
Some(v) => {
let p = v.partition_point(|&x| x < line);
v.get(p).or_else(|| v.last()).copied().unwrap_or(0)
}
}
}
fn truncate_pad(s: &mut String, cols: usize) {
let mut w = 0usize;
let mut keep = String::new();
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > cols {
break;
}
keep.push(ch);
w += cw;
}
while w < cols {
keep.push(' ');
w += 1;
}
*s = keep;
}
fn match_ranges(plain: &str, search: Option<&SearchState>) -> Vec<(usize, usize)> {
let Some(s) = search else { return Vec::new() };
s.re
.find_iter(plain)
.map(|m| (m.start(), m.end()))
.filter(|(a, b)| b > a)
.collect()
}
struct SgrOut {
out: String,
prev_sgr: Option<String>,
}
impl SgrOut {
fn new() -> Self {
SgrOut {
out: String::new(),
prev_sgr: None,
}
}
fn push(&mut self, ch: char, fg: Color, invert: bool) {
let inv = if invert { ";7" } else { "" };
let sgr = format!("\x1b[0{};38;2;{};{};{}m", inv, fg.r, fg.g, fg.b);
if self.prev_sgr.as_deref() != Some(sgr.as_str()) {
self.out.push_str(&sgr);
self.prev_sgr = Some(sgr);
}
self.out.push(ch);
}
fn finish(mut self) -> String {
if self.prev_sgr.is_some() {
self.out.push_str("\x1b[0m");
}
self.out
}
}
fn render_segment(line: &Line, range: (usize, usize), search: Option<&SearchState>) -> String {
let (start, end) = range;
let plain = line_plain(line);
let matches = match_ranges(&plain, search);
let in_match = |byte: usize| matches.iter().any(|&(a, b)| byte >= a && byte < b);
let mut out = SgrOut::new();
let mut byte_pos: usize = 0;
for (style, text) in &line.spans {
for ch in text.chars() {
if byte_pos >= start && byte_pos < end && UnicodeWidthChar::width(ch).unwrap_or(0) > 0 {
out.push(ch, style.foreground, in_match(byte_pos));
}
byte_pos += ch.len_utf8();
}
}
out.finish()
}
fn render_line(line: &Line, left: usize, cols: usize, search: Option<&SearchState>) -> String {
let plain = line_plain(line);
let matches = match_ranges(&plain, search);
let in_match = |byte: usize| matches.iter().any(|&(a, b)| byte >= a && byte < b);
let end = left.saturating_add(cols);
let mut out = SgrOut::new();
let mut col: usize = 0;
let mut byte_pos: usize = 0;
for (style, text) in &line.spans {
for ch in text.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if cw > 0 && col >= left && col + cw <= end {
out.push(ch, style.foreground, in_match(byte_pos));
}
col += cw;
byte_pos += ch.len_utf8();
}
}
out.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::highlight::{Color, Line, Style};
fn line(text: &str) -> Line {
Line {
spans: vec![(
Style {
foreground: Color { r: 137, g: 180, b: 250 },
},
text.to_string(),
)],
}
}
fn search(pattern: &str) -> SearchState {
SearchState {
re: Regex::new(pattern).unwrap(),
dir: SearchDir::Forward,
}
}
fn filt(pat: &str, negate: bool) -> Filter {
Filter {
re: smartcase_regex(pat).unwrap(),
negate,
}
}
#[test]
fn build_view_none_when_no_filters() {
let lines = vec![line("a"), line("b")];
assert_eq!(build_view(&lines, &[]), None);
}
#[test]
fn build_view_keeps_matching_lines() {
let lines = vec![line("apple"), line("banana"), line("apricot")];
assert_eq!(build_view(&lines, &[filt("ap", false)]), Some(vec![0, 2]));
}
#[test]
fn build_view_negate_excludes_matches() {
let lines = vec![line("apple"), line("banana"), line("apricot")];
assert_eq!(build_view(&lines, &[filt("ap", true)]), Some(vec![1]));
}
#[test]
fn build_view_multiple_filters_are_anded() {
let lines = vec![line("apple pie"), line("apple"), line("cherry pie")];
let filters = vec![filt("apple", false), filt("pie", false)];
assert_eq!(build_view(&lines, &filters), Some(vec![0]));
}
#[test]
fn build_view_smart_case_is_insensitive_for_lowercase() {
let lines = vec![line("APPLE"), line("banana")];
assert_eq!(build_view(&lines, &[filt("apple", false)]), Some(vec![0]));
}
#[test]
fn visible_helpers_identity_when_unfiltered() {
let n = 3;
assert_eq!(first_visible(None, n), Some(0));
assert_eq!(last_visible(None, n), Some(2));
assert_eq!(next_visible(None, n, 0), Some(1));
assert_eq!(next_visible(None, n, 2), None);
assert_eq!(prev_visible(None, 1), Some(0));
assert_eq!(prev_visible(None, 0), None);
assert_eq!(snap_visible(None, n, 5), 2);
assert_eq!(snap_visible(None, n, 1), 1);
}
#[test]
fn visible_helpers_skip_hidden_lines() {
let v = vec![1usize, 4, 7];
let view = Some(v.as_slice());
let n = 10;
assert_eq!(first_visible(view, n), Some(1));
assert_eq!(last_visible(view, n), Some(7));
assert_eq!(next_visible(view, n, 1), Some(4));
assert_eq!(next_visible(view, n, 4), Some(7));
assert_eq!(next_visible(view, n, 7), None);
assert_eq!(next_visible(view, n, 2), Some(4));
assert_eq!(prev_visible(view, 7), Some(4));
assert_eq!(prev_visible(view, 4), Some(1));
assert_eq!(prev_visible(view, 1), None);
assert_eq!(prev_visible(view, 5), Some(4));
}
#[test]
fn snap_visible_rounds_to_nearest_at_or_after() {
let v = vec![2usize, 5, 9];
let view = Some(v.as_slice());
assert_eq!(snap_visible(view, 10, 0), 2); assert_eq!(snap_visible(view, 10, 5), 5); assert_eq!(snap_visible(view, 10, 6), 9); assert_eq!(snap_visible(view, 10, 99), 9); }
#[test]
fn visible_helpers_empty() {
assert_eq!(first_visible(None, 0), None);
assert_eq!(last_visible(None, 0), None);
}
#[test]
fn highlights_match_with_inverse_sgr() {
let l = line("fn main() -> ExitCode {");
let s = search("main");
let rendered = render_line(&l, 0, 80, Some(&s));
assert!(
rendered.contains("\x1b[0;7;38;2;"),
"expected inverse SGR for match, got {:?}",
rendered
);
assert!(rendered.contains("main"));
}
#[test]
fn no_inverse_when_search_inactive() {
let l = line("fn main() -> ExitCode {");
let rendered = render_line(&l, 0, 80, None);
assert!(!rendered.contains("\x1b[7m"));
assert!(!rendered.contains("\x1b[0;7;"));
}
#[test]
fn match_at_line_start() {
let l = line("main main");
let s = search("main");
let rendered = render_line(&l, 0, 80, Some(&s));
let n = rendered.matches("\x1b[0;7;").count();
assert!(n >= 2, "expected >=2 inverse SGR, got {} in {:?}", n, rendered);
}
#[test]
fn wrap_exact_fit_is_one_segment() {
let l = line("abcde"); assert_eq!(wrap_ranges(&l, 5), vec![(0, 5)]);
}
#[test]
fn wrap_overflow_breaks() {
let l = line("abcdef"); assert_eq!(wrap_ranges(&l, 5), vec![(0, 5), (5, 6)]);
}
#[test]
fn wrap_empty_line_is_single_segment() {
let l = line("");
assert_eq!(wrap_ranges(&l, 5), vec![(0, 0)]);
}
#[test]
fn wrap_wide_char_straddling_boundary() {
let l = line("aあb");
assert_eq!(wrap_ranges(&l, 2), vec![(0, 1), (1, 4), (4, 5)]);
}
#[test]
fn wrap_zero_width_char_stays_in_segment() {
let l = line("a\tb"); assert_eq!(wrap_ranges(&l, 2), vec![(0, 3)]);
}
#[test]
fn wrap_char_wider_than_screen_gets_own_row() {
let l = line("あい");
assert_eq!(wrap_ranges(&l, 1), vec![(0, 3), (3, 6)]);
}
#[test]
fn clamp_pos_guards_sub_overflow() {
let lines = vec![line("abcdef")];
assert_eq!(clamp_pos(&lines, 5, true, 0, 9), (0, 1));
assert_eq!(clamp_pos(&lines, 10, true, 0, 9), (0, 0));
}
#[test]
fn clamp_pos_chop_mode_sub_is_zero() {
let lines = vec![line("abcdef")];
assert_eq!(clamp_pos(&lines, 5, false, 0, 3), (0, 0));
}
#[test]
fn clamp_pos_clamps_top_to_last_line() {
let lines = vec![line("a"), line("b")];
assert_eq!(clamp_pos(&lines, 5, true, 9, 0), (1, 0));
}
#[test]
fn clamp_pos_empty_file() {
let lines: Vec<Line> = Vec::new();
assert_eq!(clamp_pos(&lines, 5, true, 3, 2), (0, 0));
}
#[test]
fn digit_count_boundaries() {
assert_eq!(digit_count(0), 1);
assert_eq!(digit_count(9), 1);
assert_eq!(digit_count(10), 2);
assert_eq!(digit_count(99), 2);
assert_eq!(digit_count(100), 3);
}
#[test]
fn gutter_width_tracks_line_count() {
assert_eq!(gutter_width(9, true), 2); assert_eq!(gutter_width(10, true), 3); assert_eq!(gutter_width(1000, true), 5); assert_eq!(gutter_width(1000, false), 0); }
#[test]
fn line_numbers_narrow_the_wrap_width() {
let l = line("abcdef");
let content_cols = 8 - gutter_width(50, true); assert_eq!(content_cols, 5);
assert_eq!(wrap_ranges(&l, content_cols), vec![(0, 5), (5, 6)]);
}
#[test]
fn gutter_blank_on_continuation_rows() {
let first = gutter(42, true, 3);
let cont = gutter(42, false, 3);
assert!(first.contains("42"));
assert_eq!(cont, " "); assert!(!cont.contains("42"));
}
fn pager_with(n: usize) -> Pager {
let lines: Vec<Line> = (0..n).map(|i| line(&format!("line {}", i))).collect();
let mut p = Pager::new(vec![Source::memory("t", lines)], false, false, StartAction::None).unwrap();
p.cols = 80;
p.rows = 24;
p
}
fn test_pager(lines: Vec<Line>) -> Pager {
let mut p = pager_with(lines.len());
p.lines = lines;
p
}
#[test]
fn mark_set_and_jump() {
let mut p = pager_with(100);
p.goto_line(50);
p.marks.insert('a', (p.top, p.sub));
p.goto_line(10);
assert_eq!(p.top, 10);
p.goto_mark('a');
assert_eq!(p.top, 50);
}
#[test]
fn quote_quote_returns_to_previous_position() {
let mut p = pager_with(100);
p.goto_line(10);
p.marks.insert('a', (50, 0));
p.goto_mark('a'); assert_eq!(p.top, 50);
p.goto_mark('\''); assert_eq!(p.top, 10);
}
#[test]
fn jump_to_unset_mark_reports_and_stays() {
let mut p = pager_with(100);
p.goto_mark('z');
assert!(p.message.is_some());
assert_eq!(p.top, 0);
}
#[test]
fn switch_between_files_resets_view() {
let a: Vec<Line> = (0..10).map(|i| line(&format!("a{}", i))).collect();
let b: Vec<Line> = (0..5).map(|i| line(&format!("b{}", i))).collect();
let mut p =
Pager::new(vec![Source::memory("a", a), Source::memory("b", b)], false, false, StartAction::None).unwrap();
p.cols = 80;
p.rows = 24;
assert_eq!((p.index, p.lines.len()), (0, 10));
p.goto_line(5);
p.next_file();
assert_eq!((p.index, p.lines.len()), (1, 5));
assert_eq!(p.top, 0);
p.next_file(); assert_eq!(p.index, 1);
p.prev_file();
assert_eq!((p.index, p.lines.len()), (0, 10));
}
#[test]
fn follow_reloads_on_growth() {
use std::io::Write as _;
let path = std::env::temp_dir().join(format!("cless_follow_{}.txt", std::process::id()));
std::fs::write(&path, "line1\n").unwrap();
let mut p =
Pager::new(vec![Source::file("f", path.to_str().unwrap())], false, false, StartAction::None).unwrap();
p.cols = 80;
p.rows = 24;
let n0 = p.lines.len();
p.follow_len = std::fs::metadata(&path).unwrap().len();
p.following = true;
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap();
writeln!(f, "line2").unwrap();
writeln!(f, "line3").unwrap();
drop(f);
assert!(p.poll_growth().unwrap(), "growth should trigger reload");
assert!(p.lines.len() > n0, "reloaded file should have more lines");
assert!(!p.poll_growth().unwrap());
std::fs::remove_file(&path).ok();
}
#[test]
fn info_string_notes_active_filter() {
let mut p = test_pager(vec![line("apple"), line("banana"), line("apricot")]);
assert!(!p.info_string().contains("[filtered"));
p.filters.push(filt("ap", false));
p.apply_filters();
let info = p.info_string();
assert!(info.contains("[filtered 2/3]"), "got: {}", info);
}
#[test]
fn parse_plus_forms() {
assert!(matches!(parse_plus("G"), Some(StartAction::End)));
assert!(matches!(parse_plus("/foo"), Some(StartAction::Search(ref s)) if s == "foo"));
assert!(matches!(parse_plus("42"), Some(StartAction::Line(41)))); assert!(matches!(parse_plus("1"), Some(StartAction::Line(0))));
assert!(parse_plus("bogus").is_none());
assert!(parse_plus("").is_none());
}
}