#![allow(dead_code)]
#[cfg(feature = "search")]
use crate::search::{SearchMode, SearchOpts, next_nth_match};
use crate::{
LineNumbers, OutputSink,
error::{MinusError, TermError},
hooks::{Hook, Hooks},
input::{self, HashedEventRegister},
minus_core::{
self, CommandQueue,
utils::{
LinesRowMap,
display::{self, AppendStyle},
},
},
screen::{self, Screen},
};
use crossterm::terminal;
use parking_lot::Mutex;
#[cfg(feature = "search")]
use std::collections::BTreeSet;
use std::{
borrow::Cow,
collections::hash_map::RandomState,
convert::TryInto,
sync::{Arc, atomic::AtomicBool},
};
use crate::minus_core::{commands::Command, ev_handler::handle_event};
use crossbeam_channel::Receiver;
#[cfg(feature = "search")]
#[cfg_attr(docsrs, doc(cfg(feature = "search")))]
#[allow(clippy::module_name_repetitions)]
pub struct SearchState {
pub search_mode: SearchMode,
pub(crate) search_term: Option<regex::Regex>,
pub(crate) search_idx: BTreeSet<usize>,
pub(crate) search_mark: usize,
pub(crate) incremental_search_condition:
Box<dyn Fn(&SearchOpts) -> bool + Send + Sync + 'static>,
pub smart_case: bool,
}
#[cfg(feature = "search")]
impl Default for SearchState {
fn default() -> Self {
let incremental_search_condition = Box::new(|so: &SearchOpts| {
so.string.len() > 1
&& so
.incremental_search_options
.as_ref()
.unwrap()
.screen
.line_count()
<= 5000
});
Self {
search_mode: SearchMode::Unknown,
search_term: None,
search_idx: BTreeSet::new(),
search_mark: 0,
incremental_search_condition,
smart_case: false,
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub struct Selection {
pub absolute_row: usize,
pub col: usize,
}
#[cfg(feature = "clipboard")]
#[cfg_attr(docsrs, cfg(feature = "clipboard"))]
pub type ClipboardHandler = Box<dyn Fn(&str) + Send + Sync + 'static>;
#[derive(Clone, Debug)]
pub(crate) struct HelpState {
pub(crate) screen: Screen,
pub(crate) upper_mark: usize,
pub(crate) left_mark: usize,
pub(crate) prompt: String,
pub(crate) follow_output: bool,
pub(crate) line_numbers: LineNumbers,
}
#[allow(clippy::module_name_repetitions)]
pub struct PagerState {
pub line_numbers: LineNumbers,
pub message: Option<String>,
pub upper_mark: usize,
pub left_mark: usize,
#[cfg(feature = "search")]
#[cfg_attr(docsrs, cfg(feature = "search"))]
pub search_mode: SearchMode,
pub rows: usize,
pub cols: usize,
pub prefix_num: String,
pub running: &'static Mutex<crate::RunMode>,
#[cfg(feature = "search")]
#[cfg_attr(docsrs, cfg(feature = "search"))]
pub search_state: SearchState,
pub screen: Screen,
pub selection: Option<Selection>,
pub(crate) prompt: String,
pub(crate) input_classifier: Box<dyn input::InputClassifier + Sync + Send>,
#[cfg(feature = "clipboard")]
pub(crate) clipboard_handler: Option<ClipboardHandler>,
pub(crate) exit_callbacks: Vec<Box<dyn FnMut() + Send + Sync + 'static>>,
pub(crate) hooks: Hooks,
pub(crate) displayed_prompt: String,
pub(crate) show_prompt: bool,
#[cfg(feature = "static_output")]
pub(crate) run_no_overflow: bool,
pub(crate) lines_to_row_map: LinesRowMap,
pub(crate) follow_output: bool,
pub(crate) selection_anchor: Option<Selection>,
pub(crate) help_state: Option<HelpState>,
pub output_sink: Arc<Mutex<Box<dyn OutputSink>>>,
}
impl PagerState {
pub(crate) fn new() -> Result<Self, TermError> {
#[cfg(not(test))]
let default_sink: Box<dyn OutputSink> = Box::new(std::io::stdout());
#[cfg(test)]
let default_sink: Box<dyn OutputSink> = Box::new(Vec::new());
let output_sink = Arc::new(Mutex::new(default_sink));
let is_tty = output_sink.lock().is_tty();
let (cols, rows) = if cfg!(test) {
(80, 10)
} else if is_tty {
let size = terminal::size()?;
(size.0 as usize, size.1 as usize)
} else {
(1, 1)
};
let prompt = std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("minus"))
.file_name()
.map_or_else(
|| std::ffi::OsString::from("minus"),
std::ffi::OsStr::to_os_string,
)
.into_string()
.unwrap_or_else(|_| String::from("minus"));
let mut state = Self {
line_numbers: LineNumbers::Disabled,
upper_mark: 0,
prompt,
running: &minus_core::RUNMODE,
left_mark: 0,
input_classifier: Box::<HashedEventRegister<RandomState>>::default(),
#[cfg(feature = "clipboard")]
clipboard_handler: None,
exit_callbacks: Vec::with_capacity(5),
hooks: Hooks::new(),
message: None,
screen: Screen::default(),
selection: None,
displayed_prompt: String::new(),
show_prompt: true,
#[cfg(feature = "static_output")]
run_no_overflow: false,
#[cfg(feature = "search")]
search_mode: SearchMode::default(),
#[cfg(feature = "search")]
search_state: SearchState::default(),
cols,
rows,
prefix_num: String::new(),
lines_to_row_map: LinesRowMap::new(),
follow_output: false,
selection_anchor: None,
help_state: None,
output_sink,
};
state.hooks.add_callback(
Hook::PostPagerExit,
1,
Box::new(|_| {
std::process::exit(0);
}),
);
state.format_prompt();
Ok(state)
}
pub(crate) fn generate_initial_state(rx: &Receiver<Command>) -> Result<Self, MinusError> {
let mut ps = Self::new()?;
let mut command_queue = CommandQueue::new_zero();
rx.try_iter().for_each(|ev| {
handle_event(
ev,
&mut ps,
&mut command_queue,
&Arc::new(AtomicBool::new(false)),
);
});
Ok(ps)
}
pub(crate) fn reformat_display(&mut self) {
let format_result = screen::format_lines_into(
&mut self.screen.formatted_lines,
&self.screen.orig_text,
self.line_numbers,
self.cols,
self.screen.line_wrapping,
#[cfg(feature = "search")]
self.search_state.search_term.as_ref(),
);
#[cfg(feature = "search")]
{
self.search_state.search_idx = format_result.append_search_idx;
self.search_state.search_mark =
next_nth_match(&self.search_state.search_idx, self.upper_mark, 0).unwrap_or(0);
}
self.lines_to_row_map = format_result.lines_to_row_map;
self.screen.max_line_length = format_result.max_line_length;
self.screen.unterminated = format_result.num_unterminated;
self.format_prompt();
}
pub(crate) fn format_prompt(&mut self) {
const PROMPT_SPEC: &str = "\x1b[2;40;37m";
const SEARCH_SPEC: &str = "\x1b[30;44m";
const INPUT_SPEC: &str = "\x1b[30;43m";
const MSG_SPEC: &str = "\x1b[30;1;41m";
const RESET: &str = "\x1b[0m";
const FOLLOW_MODE_SPEC: &str = "\x1b[1m";
let mut format_string = String::with_capacity(self.cols + (SEARCH_SPEC.len() * 5) + 4);
#[cfg(feature = "search")]
let mut search_str = String::new();
#[cfg(feature = "search")]
if !self.search_state.search_idx.is_empty() {
search_str.push(' ');
search_str.push_str(&(self.search_state.search_mark + 1).to_string());
search_str.push('/');
search_str.push_str(&self.search_state.search_idx.len().to_string());
search_str.push(' ');
}
let mut prefix_str = String::new();
if !self.prefix_num.is_empty() {
prefix_str.push(' ');
prefix_str.push_str(&self.prefix_num);
prefix_str.push(' ');
}
let prompt_str = self.message.as_ref().unwrap_or(&self.prompt);
#[cfg(feature = "search")]
let search_len = search_str.len();
#[cfg(not(feature = "search"))]
let search_len = 0;
let follow_mode_str: &str = if self.follow_output { "[F]" } else { "" };
let prefix_len = prefix_str.len();
let indicators_len = search_len + prefix_len + follow_mode_str.len();
let available_space = self.cols.saturating_sub(indicators_len);
let extra_space = available_space.saturating_sub(prompt_str.chars().count());
let byte_idx = prompt_str.char_indices().nth(available_space);
let dsp_prompt: &str = if extra_space == 0
&& let Some((idx, _)) = byte_idx
{
&prompt_str[..idx]
} else {
prompt_str
};
if self.message.is_some() {
format_string.push_str(MSG_SPEC);
} else {
format_string.push_str(PROMPT_SPEC);
}
format_string.push_str(dsp_prompt);
format_string.push_str(&" ".repeat(extra_space));
if prefix_len > 0 {
format_string.push_str(INPUT_SPEC);
format_string.push_str(&prefix_str);
}
#[cfg(feature = "search")]
if search_len > 0 {
format_string.push_str(SEARCH_SPEC);
format_string.push_str(&search_str);
}
if !follow_mode_str.is_empty() {
format_string.push_str(FOLLOW_MODE_SPEC);
format_string.push_str(follow_mode_str);
}
format_string.push_str(RESET);
self.displayed_prompt = format_string;
}
pub(crate) fn show_help(&mut self) {
if self.help_state.is_some() {
return;
}
let help_text = self
.input_classifier
.format_help()
.unwrap_or_default();
let saved = HelpState {
screen: std::mem::take(&mut self.screen),
upper_mark: self.upper_mark,
left_mark: self.left_mark,
prompt: std::mem::take(&mut self.prompt),
follow_output: self.follow_output,
line_numbers: self.line_numbers,
};
self.screen = Screen::default();
self.screen.orig_text = help_text;
self.screen.line_count = self.screen.orig_text.lines().count();
self.screen.line_wrapping = false;
self.upper_mark = 0;
self.left_mark = 0;
self.follow_output = false;
self.line_numbers = LineNumbers::Disabled;
self.prompt = "HELP -- Press q to return to pager".to_string();
self.message = None;
self.help_state = Some(saved);
self.reformat_display();
}
pub(crate) fn exit_help(&mut self) {
if let Some(saved) = self.help_state.take() {
self.screen = saved.screen;
self.upper_mark = saved.upper_mark;
self.left_mark = saved.left_mark;
self.prompt = saved.prompt;
self.follow_output = saved.follow_output;
self.line_numbers = saved.line_numbers;
self.message = None;
self.reformat_display();
}
}
pub(crate) fn run_hooks(&mut self, hook: crate::hooks::Hook) {
let mut hooks = std::mem::take(&mut self.hooks);
hooks.run_hooks(hook, self);
self.hooks = hooks;
}
pub(crate) fn exit(&mut self) {
for func in &mut self.exit_callbacks {
func();
}
}
#[cfg(feature = "search")]
#[cfg_attr(docsrs, doc(cfg(feature = "search")))]
pub const fn set_smart_case(&mut self, smart_case: bool) {
self.search_state.smart_case = smart_case;
}
pub(crate) fn selection_from_coordinates(&self, x: u16, y: u16) -> Option<Selection> {
let writable_rows = self.rows.saturating_sub(1);
let row_count = self.screen.formatted_lines_count();
if row_count == 0 || usize::from(y) >= writable_rows {
return None;
}
let absolute_row = self
.upper_mark
.saturating_add(usize::from(y))
.min(row_count - 1);
let mut col = usize::from(x).saturating_sub(self.line_number_padding());
if !self.screen.line_wrapping {
col = col.saturating_add(self.left_mark);
}
Some(Selection { absolute_row, col })
}
pub(crate) const fn clear_selection(&mut self) {
self.selection = None;
self.selection_anchor = None;
}
pub(crate) fn extract_selection(&self) -> Option<String> {
let (start, end) = self.normalized_selection()?;
let lines = self.screen.orig_text.lines().collect::<Vec<_>>();
let start_line = self.lines_to_row_map.row_to_line(start.absolute_row)?;
let end_line = self.lines_to_row_map.row_to_line(end.absolute_row)?;
let mut selected = Vec::with_capacity(end_line.saturating_sub(start_line) + 1);
for line_idx in start_line..=end_line {
let raw_line = *lines.get(line_idx)?;
let line = strip_ansi(raw_line);
let line_len = line.chars().count();
let start_col = if line_idx == start_line {
self.selection_col_in_line(start, line_idx, &line)
.min(line_len)
} else {
0
};
let end_col = if line_idx == end_line {
self.selection_col_in_line(end, line_idx, &line)
.saturating_add(1)
.min(line_len)
} else {
line_len
};
selected.push(slice_chars(&line, start_col, end_col).to_string());
}
Some(selected.join("\n"))
}
pub(crate) fn render_rows_for_display(&self, start: usize, end: usize) -> Vec<Cow<'_, str>> {
(start..end)
.filter_map(|absolute_row| self.render_row_for_display(absolute_row))
.collect()
}
fn render_row_for_display(&self, absolute_row: usize) -> Option<Cow<'_, str>> {
let raw_row = self.screen.formatted_lines.get(absolute_row)?;
let Some((start_col, end_col)) = self.selection_bounds_for_row(absolute_row) else {
return Some(if self.screen.line_wrapping {
raw_row.into()
} else {
self.crop_row_for_horizontal_scroll(raw_row)
});
};
let prefix_width = self.line_number_padding();
if self.screen.line_wrapping {
return Some(highlight_visible_range(
Cow::Borrowed(raw_row),
prefix_width.saturating_add(start_col),
prefix_width.saturating_add(end_col),
));
}
let row = self.crop_row_for_horizontal_scroll(raw_row);
let visible_start = start_col.saturating_sub(self.left_mark);
let visible_end = end_col.saturating_sub(self.left_mark);
Some(highlight_visible_range(
row,
prefix_width.saturating_add(visible_start),
prefix_width.saturating_add(visible_end),
))
}
fn crop_row_for_horizontal_scroll<'a>(&self, row: &'a str) -> Cow<'a, str> {
let (first_end, second_start, second_end) = display::get_horizontal_scroll_bounds(
row,
self.cols,
self.left_mark,
self.line_numbers.is_on(),
self.screen.line_count(),
);
if self.left_mark < row.len() {
if self.line_numbers.is_on() {
format!("{}{}", &row[..first_end], &row[second_start..second_end]).into()
} else {
row[second_start..second_end].into()
}
} else {
Cow::Borrowed("")
}
}
const fn line_number_padding(&self) -> usize {
if self.line_numbers.is_on() {
minus_core::utils::digits(self.screen.line_count()) + LineNumbers::EXTRA_PADDING + 2
} else {
0
}
}
fn selection_bounds_for_row(&self, absolute_row: usize) -> Option<(usize, usize)> {
let (start, end) = self.normalized_selection()?;
if absolute_row < start.absolute_row || absolute_row > end.absolute_row {
return None;
}
let start_col = if absolute_row == start.absolute_row {
start.col
} else {
0
};
let end_col = if absolute_row == end.absolute_row {
end.col.saturating_add(1)
} else {
usize::MAX
};
Some((start_col, end_col))
}
fn normalized_selection(&self) -> Option<(Selection, Selection)> {
let s_start = self.selection_anchor?;
let s_end = self.selection?;
Some(
if s_start.absolute_row > s_end.absolute_row
|| (s_start.absolute_row == s_end.absolute_row && s_start.col > s_end.col)
{
(s_end, s_start)
} else {
(s_start, s_end)
},
)
}
fn selection_col_in_line(&self, selection: Selection, line_idx: usize, line: &str) -> usize {
if !self.screen.line_wrapping {
return selection.col;
}
let Some(&line_start_row) = self.lines_to_row_map.get(line_idx) else {
return selection.col;
};
let row_in_line = selection.absolute_row.saturating_sub(line_start_row);
let cols_avail = self.wrapped_cols_available();
let wrapped_rows = textwrap::wrap(line, cols_avail.max(1));
let preceding_chars = wrapped_rows
.iter()
.take(row_in_line)
.map(|row| row.chars().count())
.sum::<usize>();
preceding_chars.saturating_add(selection.col)
}
const fn wrapped_cols_available(&self) -> usize {
if self.line_numbers.is_on() {
let padding = minus_core::utils::digits(self.screen.line_count())
+ LineNumbers::EXTRA_PADDING
+ 1;
self.cols.saturating_sub(padding + 2)
} else {
self.cols
}
}
pub(crate) fn append_str(&mut self, text: &str) -> AppendStyle {
let old_lc = self.screen.line_count();
let old_lc_dgts = minus_core::utils::digits(old_lc);
let mut append_result = self.screen.push_screen_buf(
text,
self.line_numbers,
self.cols.try_into().unwrap(),
#[cfg(feature = "search")]
self.search_state.search_term.as_ref(),
);
let new_lc = self.screen.line_count();
let new_lc_dgts = minus_core::utils::digits(new_lc);
#[cfg(feature = "search")]
{
let mut append_search_idx = append_result.append_search_idx;
self.search_state.search_idx.append(&mut append_search_idx);
}
self.lines_to_row_map.append(
&mut append_result.lines_to_row_map,
append_result.clean_append,
);
if self.line_numbers.is_on() && (new_lc_dgts != old_lc_dgts && old_lc_dgts != 0) {
self.reformat_display();
return AppendStyle::FullRedraw;
}
let total_rows = self.screen.formatted_lines_count();
AppendStyle::PartialUpdate((total_rows - append_result.rows_formatted, total_rows))
}
}
fn slice_chars(line: &str, start: usize, end: usize) -> &str {
let mut indices = line
.char_indices()
.map(|(idx, _)| idx)
.chain(std::iter::once(line.len()));
let start_byte = indices.nth(start).unwrap_or(line.len());
let end_byte = indices
.nth(end.saturating_sub(start + 1))
.unwrap_or(line.len());
&line[start_byte..end_byte]
}
fn highlight_visible_range(line: Cow<str>, start: usize, end: usize) -> Cow<str> {
const REVERSE: &str = "\x1b[7m";
const RESET: &str = "\x1b[27m";
if start >= end {
return line;
}
let bytes = line.as_bytes();
let mut out = String::with_capacity(line.len() + REVERSE.len() + RESET.len());
let mut byte_idx = 0;
let mut visible_idx = 0;
let mut highlighted = false;
while byte_idx < bytes.len() {
if highlighted && visible_idx == end {
out.push_str(RESET);
highlighted = false;
}
if !highlighted && visible_idx == start {
out.push_str(REVERSE);
highlighted = true;
}
if bytes[byte_idx] == b'\x1b' && bytes.get(byte_idx + 1) == Some(&b'[') {
let esc_start = byte_idx;
byte_idx += 2;
let mut final_byte = 0;
while byte_idx < bytes.len() {
let byte = bytes[byte_idx];
byte_idx += 1;
if (0x40..=0x7e).contains(&byte) {
final_byte = byte;
break;
}
}
out.push_str(&line[esc_start..byte_idx]);
if highlighted && final_byte == b'm' {
out.push_str(REVERSE);
}
continue;
}
let ch = line[byte_idx..].chars().next().unwrap();
out.push(ch);
visible_idx += 1;
byte_idx += ch.len_utf8();
}
if highlighted {
out.push_str(RESET);
}
out.into()
}
pub(crate) fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\x1b' {
if i + 1 < bytes.len() {
match bytes[i + 1] {
b'[' => {
i += 2;
while i < bytes.len() {
let b = bytes[i];
i += 1;
if (0x40..=0x7e).contains(&b) {
break;
}
}
}
b']' => {
i += 2;
while i < bytes.len() {
if bytes[i] == 0x07 {
i += 1;
break;
}
if bytes[i] == b'\x1b' && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
i += 2;
break;
}
i += 1;
}
}
_ => {
i += 2;
}
}
} else {
i += 1;
}
} else if bytes[i] == 0x9b {
i += 1;
while i < bytes.len() {
let b = bytes[i];
i += 1;
if (0x40..=0x7e).contains(&b) {
break;
}
}
} else {
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
}
}
out
}
#[cfg(test)]
mod tests {
use super::{PagerState, Selection, highlight_visible_range, strip_ansi};
use crate::LineNumbers;
#[test]
#[allow(clippy::cast_possible_truncation)]
fn extract_selection_uses_logical_text_coordinates() {
let mut ps = PagerState::new().unwrap();
ps.line_numbers = LineNumbers::Enabled;
ps.screen.line_wrapping = false;
ps.left_mark = 3;
ps.screen.orig_text = "abcdefghij\nklmnopqrst\nuvwxyz\n".to_string();
ps.reformat_display();
let padding = ps.line_number_padding() as u16;
ps.selection_anchor = ps.selection_from_coordinates(padding + 1, 0);
ps.selection = ps.selection_from_coordinates(padding + 1, 2);
assert_eq!(
ps.extract_selection().as_deref(),
Some("efghij\nklmnopqrst\nuvwxy")
);
}
#[test]
fn extract_selection_with_ansi_styles() {
let mut ps = PagerState::new().unwrap();
ps.line_numbers = LineNumbers::Disabled;
ps.screen.line_wrapping = false;
ps.screen.orig_text =
"\x1b[31mhello\x1b[0m \x1b[1;32mworld\x1b[0m\n\x1b[34msecond\x1b[0m line\n".to_string();
ps.reformat_display();
ps.selection_anchor = ps.selection_from_coordinates(0, 0);
ps.selection = ps.selection_from_coordinates(10, 0);
assert_eq!(ps.extract_selection().as_deref(), Some("hello world"));
ps.selection_anchor = ps.selection_from_coordinates(6, 0);
ps.selection = ps.selection_from_coordinates(10, 0);
assert_eq!(ps.extract_selection().as_deref(), Some("world"));
}
#[test]
fn test_strip_ansi() {
assert_eq!(strip_ansi(""), "");
assert_eq!(strip_ansi("plain text"), "plain text");
assert_eq!(strip_ansi("\x1b[31mhello\x1b[0m"), "hello");
assert_eq!(strip_ansi("\x1b[1;38;2;255;0;0mRGB\x1b[0m text"), "RGB text");
assert_eq!(strip_ansi("\x1b]8;;https://example.com\x07link\x1b]8;;\x07"), "link");
assert_eq!(strip_ansi("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"), "link");
}
#[test]
fn test_highlight_visible_range_with_ansi_styles() {
use std::borrow::Cow;
let line = Cow::Borrowed("\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m");
let highlighted = highlight_visible_range(line, 0, 11);
assert_eq!(
highlighted.as_ref(),
"\x1b[7m\x1b[31m\x1b[7mhello\x1b[0m\x1b[7m \x1b[32m\x1b[7mworld\x1b[27m\x1b[0m"
);
}
#[test]
fn extract_selection_across_wrapped_rows() {
let mut ps = PagerState::new().unwrap();
ps.cols = 6;
ps.screen.orig_text = "abcdefghi\njklmnop\n".to_string();
ps.reformat_display();
ps.selection_anchor = Some(Selection {
absolute_row: 0,
col: 2,
});
ps.selection = Some(Selection {
absolute_row: 2,
col: 3,
});
assert_eq!(ps.extract_selection().as_deref(), Some("cdefghi\njklm"));
}
#[test]
fn format_prompt_truncates_long_message_to_available_width() {
let mut ps = PagerState::new().unwrap();
ps.cols = 20;
let long_msg = "Help: q:quit | j/k:scroll | Space:page";
ps.message = Some(long_msg.to_string());
ps.format_prompt();
assert!(ps.displayed_prompt.contains(&long_msg[..20]));
ps.follow_output = true;
ps.format_prompt();
assert!(ps.displayed_prompt.contains(&long_msg[..17]));
assert!(ps.displayed_prompt.contains("[F]"));
}
}