#[cfg(feature = "search")]
use crate::minus_core::search::{self, SearchMode};
use crate::{
error::{MinusError, TermError},
input, wrap_str, ExitStrategy, LineNumbers,
};
use crossterm::{terminal, tty::IsTty};
#[cfg(feature = "search")]
use parking_lot::{Condvar, Mutex};
#[cfg(feature = "search")]
use std::collections::BTreeSet;
use std::io::Stdout;
use std::{
io::stdout,
sync::{atomic::AtomicBool, Arc},
};
use crate::minus_core::{ev_handler::handle_event, events::Event};
use crossbeam_channel::Receiver;
/// Holds all information and configuration about the pager during
/// its un time.
///
/// This type is exposed so that end-applications can implement the
/// [`InputClassifier`](input::InputClassifier) trait which requires the `PagerState` to be passed
/// as a parameter
///
/// Various fields are made public so that their values can be accessed while implementing the
/// trait.
#[allow(clippy::module_name_repetitions)]
pub struct PagerState {
/// The text the pager has been told to be displayed
pub(crate) lines: String,
/// The output, flattened and formatted into the lines that should be displayed
pub(crate) formatted_lines: Vec<String>,
/// Configuration for line numbers. See [`LineNumbers`]
pub line_numbers: LineNumbers,
/// Unterminated lines
/// Keeps track of the number of lines at the last of [PagerState::formatted_lines] which are
/// not terminated by a newline
pub(crate) unterminated: usize,
/// The prompt displayed at the bottom wrapped to available terminal width
pub(crate) prompt: String,
/// The input classifier to be called when a input is detected
pub(crate) input_classifier: Box<dyn input::InputClassifier + Sync + Send>,
/// Functions to run when the pager quits
pub(crate) exit_callbacks: Vec<Box<dyn FnMut() + Send + Sync + 'static>>,
/// The behaviour to do when user quits the program using `q` or `Ctrl+C`
/// See [`ExitStrategy`] for available options
pub(crate) exit_strategy: ExitStrategy,
/// Any message to display to the user at the prompt
/// The first element contains the actual message, while the second element tells
/// whether the message has changed since the last display.
pub(crate) message: Option<String>,
/// The prompt that should be displayed to the user, formatted with the
/// current search index and number of matches (if the search feature is enabled),
/// and the current numbers inputted to scroll
pub(crate) displayed_prompt: String,
/// The upper bound of scrolling.
///
/// This is useful for keeping track of the range of lines which are currently being displayed on
/// the terminal.
/// When `rows - 1` is added to the `upper_mark`, it gives the lower bound of scroll.
///
/// For example if there are 10 rows is a terminal and the data to display has 50 lines in it/
/// If the `upper_mark` is 15, then the first row of the terminal is the 16th line of the data
/// and last row is the 24th line of the data.
pub upper_mark: usize,
/// Do we want to page if there is no overflow
#[cfg(feature = "static_output")]
pub(crate) run_no_overflow: bool,
/// Stores the most recent search term
#[cfg(feature = "search")]
pub(crate) search_term: Option<regex::Regex>,
/// Direction of search
///
/// See [`SearchMode`] for available options
#[cfg(feature = "search")]
#[cfg_attr(docsrs, cfg(feature = "search"))]
pub search_mode: SearchMode,
/// Lines where searches have a match
/// In order to avoid duplicate entries of lines, we keep it in a [`BTreeSet`]
#[cfg(feature = "search")]
pub(crate) search_idx: BTreeSet<usize>,
/// Index of search item currently in focus
/// It should be 0 even when no search is in action
#[cfg(feature = "search")]
pub(crate) search_mark: usize,
/// Available rows in the terminal
pub rows: usize,
/// Available columns in the terminal
pub cols: usize,
/// This variable helps in scrolling more than one line at a time
/// It keeps track of all the numbers that have been entered by the user
/// untill any of `j`, `k`, `G`, `Up` or `Down` is pressed
pub prefix_num: String,
}
impl PagerState {
pub(crate) fn new() -> Result<Self, TermError> {
let (rows, cols);
if cfg!(test) {
// In tests, set number of columns to 80 and rows to 10
cols = 80;
rows = 10;
} else if stdout().is_tty() {
// If a proper terminal is present, get size and set it
let size = terminal::size()?;
cols = size.0 as usize;
rows = size.1 as usize;
} else {
// For other cases beyond control
cols = 1;
rows = 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 {
lines: String::with_capacity(u16::MAX.into()),
formatted_lines: Vec::with_capacity(u16::MAX.into()),
line_numbers: LineNumbers::Disabled,
upper_mark: 0,
unterminated: 0,
prompt,
exit_strategy: ExitStrategy::ProcessQuit,
input_classifier: Box::new(input::DefaultInputClassifier {}),
exit_callbacks: Vec::with_capacity(5),
message: None,
displayed_prompt: String::new(),
#[cfg(feature = "static_output")]
run_no_overflow: false,
#[cfg(feature = "search")]
search_term: None,
#[cfg(feature = "search")]
search_mode: SearchMode::default(),
#[cfg(feature = "search")]
search_idx: BTreeSet::new(),
#[cfg(feature = "search")]
search_mark: 0,
// Just to be safe in tests, keep at 1x1 size
cols,
rows,
prefix_num: String::new(),
};
state.format_prompt();
Ok(state)
}
/// Generate the initial [`PagerState`]
///
/// [`init_core`](crate::minus_core::init::init_core) calls this functions for creating the PagerState.
///
/// This function creates a default [`PagerState`] and fetches all events present in the receiver
/// to create the initial state. This is done before starting the pager so that
/// the optimizationss can be applied.
///
/// # Errors
/// This function will return an error if it could not create the default [`PagerState`] or fails
/// to process the events
pub fn generate_initial_state(
rx: &mut Receiver<Event>,
mut out: &mut Stdout,
) -> Result<Self, MinusError> {
let mut ps = Self::new()?;
rx.try_iter().try_for_each(|ev| -> Result<(), MinusError> {
handle_event(
ev,
&mut out,
&mut ps,
&Arc::new(AtomicBool::new(false)),
#[cfg(feature = "search")]
&Arc::new((Mutex::new(true), Condvar::new())),
)
})?;
Ok(ps)
}
pub(crate) fn num_lines(&self) -> usize {
self.formatted_lines.len()
}
/// Formats the given `line`
///
/// - `line_numbers` tells whether to format the line with line numbers.
/// - `len_line_number` is the length of the number of lines in [`PagerState::lines`] as in a string.
/// For example, this will be 2 if number of lines in [`PagerState::lines`] is 50 and 3 if
/// number of lines in [`PagerState::lines`] is 500. This is used for calculating the padding
/// of each displayed line.
/// - `idx` is the position index where the line is placed in [`PagerState::lines`].
/// - `formatted_idx` is the position index where the line will be placed in the resulting
/// [`PagerState::formatted_lines`]
pub(crate) fn formatted_line(
&self,
line: &str,
len_line_number: usize,
idx: usize,
#[cfg(feature = "search")] formatted_idx: usize,
#[cfg(feature = "search")] search_idx: &mut BTreeSet<usize>,
) -> Vec<String> {
let line_numbers = matches!(
self.line_numbers,
LineNumbers::Enabled | LineNumbers::AlwaysOn
);
if line_numbers {
// Padding is the space that the actual line text will be shifted to accomodate for
// in line numbers. This is equal to:-
// 1 for initial space + len_line_number + 1 for `.` sign and + 1 for the followup space
//
// We reduce this from the number of available columns as this space cannot be used for
// actual line display when wrapping the lines
let padding = len_line_number + LineNumbers::EXTRA_PADDING;
let wrapped_lines = wrap_str(line, self.cols.saturating_sub(padding + 2));
let mut formatted_rows = Vec::with_capacity(256);
let first_line = {
#[cfg_attr(not(feature = "search"), allow(unused_mut))]
let mut row = wrapped_lines.first().unwrap().to_string();
#[cfg(feature = "search")]
if let Some(st) = self.search_term.as_ref() {
// highlight the lines with matching search terms
// If a match is found, add this line's index to PagerState::search_idx
let (highlighted_row, is_match) = search::highlight_line_matches(&row, st);
if is_match {
search_idx.insert(formatted_idx);
}
row = highlighted_row;
}
if cfg!(not(test)) {
format!(
"{bold}{number: >len$}.{reset} {row}",
bold = crossterm::style::Attribute::Bold,
number = idx + 1,
len = padding,
reset = crossterm::style::Attribute::Reset,
row = row
)
} else {
// In tests, we don't care about ANSI sequences for cool looking line numbers
// hence we don't include them in tests. It just makes testing more difficult
format!(
"{number: >len$}. {row}",
number = idx + 1,
len = padding,
row = row
)
}
};
formatted_rows.push(first_line);
#[cfg_attr(not(feature = "search"), allow(unused_mut))]
#[cfg_attr(not(feature = "search"), allow(unused_variables))]
let mut lines_left = wrapped_lines
.into_iter()
.enumerate()
.skip(1)
.map(|(wrap_idx, mut row)| {
#[cfg(feature = "search")]
if let Some(st) = self.search_term.as_ref() {
// highlight the lines with matching search terms
// If a match is found, add this line's index to PagerState::search_idx
let (highlighted_row, is_match) = search::highlight_line_matches(&row, st);
if is_match {
search_idx.insert(formatted_idx + wrap_idx);
}
row = highlighted_row;
}
" ".repeat(padding + 2) + &row
})
.collect::<Vec<String>>();
formatted_rows.append(&mut lines_left);
formatted_rows
} else {
#[cfg_attr(not(feature = "search"), allow(unused_variables))]
wrap_str(line, self.cols)
.iter()
.enumerate()
.map(|(wrap_idx, row)| {
#[cfg(feature = "search")]
{
self.search_term.as_ref().map_or_else(
|| row.to_string(),
|st| {
// highlight the lines with matching search terms
// If a match is found, add this line's index to PagerState::search_idx
let (hrow, is_match) = search::highlight_line_matches(row, st);
if is_match {
search_idx.insert(formatted_idx + wrap_idx);
}
hrow
},
)
}
#[cfg(not(feature = "search"))]
row.to_string()
})
.collect::<Vec<String>>()
}
}
pub(crate) fn format_lines(&mut self) {
// Keep it for the record and don't call it unless it is really necessory as this is kinda
// expensive
let line_count = self.lines.lines().count();
// Calculate len_line_number. This will be 2 if line_count is 50 and 3 if line_count is 100 (etc)
let len_line_number = line_count.to_string().len();
// Search idx, this will get filled by the self.formatted_line function
// we will later set this to self.search_idx
#[cfg(feature = "search")]
let mut search_idx = BTreeSet::new();
let mut formatted_idx = 0;
self.formatted_lines = self
.lines
.lines()
.enumerate()
.flat_map(|(idx, line)| {
let new_line = self.formatted_line(
line,
len_line_number,
idx,
#[cfg(feature = "search")]
formatted_idx,
#[cfg(feature = "search")]
&mut search_idx,
);
formatted_idx += new_line.len();
new_line
})
.collect::<Vec<String>>();
#[cfg(feature = "search")]
{
self.search_idx = search_idx;
}
self.format_prompt();
}
/// Reformat the inputted prompt to how it should be displayed
pub(crate) fn format_prompt(&mut self) {
const SEARCH_BG: &str = "\x1b[34m";
const INPUT_BG: &str = "\x1b[33m";
// Allocate the string. Add extra space in case for the
// ANSI escape things if we do have characters typed and search showing
let mut format_string = String::with_capacity(self.cols + (SEARCH_BG.len() * 2) + 4);
// Get the string that will contain the search index/match indicator
#[cfg(feature = "search")]
let mut search_str = String::new();
#[cfg(feature = "search")]
if !self.search_idx.is_empty() {
search_str.push(' ');
search_str.push_str(&(self.search_mark + 1).to_string());
search_str.push('/');
search_str.push_str(&self.search_idx.len().to_string());
search_str.push(' ');
}
// And get the string that will contain the prefix_num
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(' ');
}
// And lastly, the string that contains the prompt or msg
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;
// Calculate how much extra padding in the middle we need between
// the prompt/message and the indicators on the right
let prefix_len = prefix_str.len();
let extra_space = self
.cols
.saturating_sub(search_len + prefix_len + prompt_str.len());
let dsp_prompt: &str = if extra_space == 0 {
&prompt_str[..self.cols - search_len - prefix_len]
} else {
prompt_str
};
// push the prompt/msg
format_string.push_str(dsp_prompt);
format_string.push_str(&" ".repeat(extra_space));
// add the prefix_num if it exists
if prefix_len > 0 {
format_string.push_str(INPUT_BG);
format_string.push_str(&prefix_str);
}
// and add the search indicator stuff if it exists
#[cfg(feature = "search")]
if search_len > 0 {
format_string.push_str(SEARCH_BG);
format_string.push_str(&search_str);
}
self.displayed_prompt = format_string;
}
/// Returns all the text within the bounds, after flattening
pub(crate) fn get_flattened_lines_with_bounds(&self, start: usize, end: usize) -> &[String] {
if start >= self.num_lines() || start > end {
&[]
} else if end >= self.num_lines() {
&self.formatted_lines[start..]
} else {
&self.formatted_lines[start..end]
}
}
/// Runs the exit callbacks
pub(crate) fn exit(&mut self) {
for func in &mut self.exit_callbacks {
func();
}
}
pub(crate) fn append_str(&mut self, text: &str) {
let (fmt_line, num_unterminated) = self.make_append_str(text);
self.append_str_on_unterminated(fmt_line, num_unterminated);
}
/// Makes the text that will be displayed and appended it to [`self.formatted_lines`]
///
/// - The first output value is the actual text rows that needs to be appended. This is wrapped
/// based on the available columns
/// - The second value is the number of rows that should be truncated from [`self.formatted_lines`]
/// before appending this line. This will be 0 if the given `text` is to be appended to
/// [`self.formatted_lines`] but will be `>0` if the given text is actually part of the
/// last appended line. This function determines this by checking whether self.lines ends with
/// `\n` after appending the text
pub(crate) fn make_append_str(&mut self, text: &str) -> (Vec<String>, usize) {
let append = self.lines.ends_with('\n') || self.lines.is_empty();
let to_format = if append {
text.to_string()
} else {
self.lines.lines().last().unwrap_or("").to_string() + text
};
let to_skip = self.lines.lines().count();
// push the text to lines
self.lines.push_str(text);
// And get how many lines of text will be shown (not how many rows, how many wrapped
// lines), and get its string length
let line_number = self.lines.lines().count();
let len_line_number = line_number.to_string().len();
// This will get filled if there is an ongoing search. We just need to append it to
// self.search_idx at the end
#[cfg(feature = "search")]
let mut append_search_idx = BTreeSet::new();
// If append is true, we take only the given text for formatting
// else we also take the last line of self.lines for formatting. This is because we nned to
// format the entire line rathar than just this part
let to_format_len = to_format.lines().count();
let lines = to_format
.lines()
.enumerate()
.map(|(idx, s)| (idx, s.to_string()))
.collect::<Vec<(usize, String)>>();
let mut fmtl = Vec::with_capacity(256);
// First line
let mut first_line = self.formatted_line(
// TODO: Remove unwrap from here
&lines.first().unwrap().1,
len_line_number,
to_skip.saturating_sub(1),
#[cfg(feature = "search")]
if append {
self.formatted_lines.len()
} else {
self.formatted_lines.len().saturating_sub(1)
},
#[cfg(feature = "search")]
&mut append_search_idx,
);
// Format the last line, only if first line and last line are different. We can check this
// by seeing whether to_format_len is greater than 1
let last_line = if to_format_len > 1 {
Some(self.formatted_line(
&lines.last().unwrap().1,
len_line_number,
to_format_len + to_skip.saturating_sub(1),
#[cfg(feature = "search")]
self.formatted_lines.len(),
#[cfg(feature = "search")]
&mut append_search_idx,
))
} else {
None
};
// Format all other lines except the first and last line
let mut mid_lines = lines
.iter()
.skip(1)
.take(lines.len().saturating_sub(2))
.flat_map(|(idx, line)| {
self.formatted_line(
line,
len_line_number,
idx + to_skip.saturating_sub(1),
#[cfg(feature = "search")]
self.formatted_lines.len(),
#[cfg(feature = "search")]
&mut append_search_idx,
)
})
.collect::<Vec<String>>();
let unterminated = if self.lines.ends_with('\n') {
0
} else if to_format_len > 1 {
last_line.as_ref().unwrap().len()
} else {
first_line.len()
};
fmtl.append(&mut first_line);
fmtl.append(&mut mid_lines);
if let Some(mut ll) = last_line {
fmtl.append(&mut ll);
}
#[cfg(feature = "search")]
self.search_idx.append(&mut append_search_idx);
(fmtl, unterminated)
}
/// Conditionally appends to [`self.formatted_lines`] or changes the last unterminated rows of
/// [`self.formatted_lines`]
///
/// `num_unterminated` is the current number of lines returned by [`self.make_append_str`]
/// that should be truncated from [`self.formatted_lines`] to update the last line
pub(crate) fn append_str_on_unterminated(
&mut self,
mut fmt_line: Vec<String>,
num_unterminated: usize,
) {
if num_unterminated != 0 || self.unterminated != 0 {
self.formatted_lines
.truncate(self.formatted_lines.len() - self.unterminated);
}
self.formatted_lines.append(&mut fmt_line);
self.unterminated = num_unterminated;
}
}