Skip to main content

minus/
state.rs

1//! Contains types that hold run-time information of the pager.
2
3#![allow(dead_code)]
4#[cfg(feature = "search")]
5use crate::search::{SearchMode, SearchOpts, next_nth_match};
6
7use crate::{
8    LineNumbers, OutputSink,
9    error::{MinusError, TermError},
10    hooks::{Hook, Hooks},
11    input::{self, HashedEventRegister},
12    minus_core::{
13        self, CommandQueue,
14        utils::{
15            LinesRowMap,
16            display::{self, AppendStyle},
17        },
18    },
19    screen::{self, Screen},
20};
21use crossterm::terminal;
22use parking_lot::Mutex;
23#[cfg(feature = "search")]
24use std::collections::BTreeSet;
25use std::{
26    borrow::Cow,
27    collections::hash_map::RandomState,
28    convert::TryInto,
29    sync::{Arc, atomic::AtomicBool},
30};
31
32use crate::minus_core::{commands::Command, ev_handler::handle_event};
33use crossbeam_channel::Receiver;
34
35#[cfg(feature = "search")]
36#[cfg_attr(docsrs, doc(cfg(feature = "search")))]
37#[allow(clippy::module_name_repetitions)]
38/// Contains information about the current search
39pub struct SearchState {
40    /// Direction of search
41    ///
42    /// See [`SearchMode`] for available options
43    pub search_mode: SearchMode,
44    /// Stores the most recent search term
45    pub(crate) search_term: Option<regex::Regex>,
46    /// Lines where searches have a match
47    /// In order to avoid duplicate entries of lines, we keep it in a [`BTreeSet`]
48    pub(crate) search_idx: BTreeSet<usize>,
49    /// Index of search item currently in focus
50    /// It should be 0 even when no search is in action
51    pub(crate) search_mark: usize,
52    /// Function to run before running an incremental search.
53    ///
54    /// If the function returns a `false`, the incremental search is cancelled.
55    pub(crate) incremental_search_condition:
56        Box<dyn Fn(&SearchOpts) -> bool + Send + Sync + 'static>,
57    /// Whether smart case search is enabled.
58    ///
59    /// When enabled, search queries containing no uppercase characters are case-insensitive,
60    /// while queries containing uppercase characters remain case-sensitive.
61    pub smart_case: bool,
62}
63
64#[cfg(feature = "search")]
65impl Default for SearchState {
66    fn default() -> Self {
67        let incremental_search_condition = Box::new(|so: &SearchOpts| {
68            so.string.len() > 1
69                && so
70                    .incremental_search_options
71                    .as_ref()
72                    .unwrap()
73                    .screen
74                    .line_count()
75                    <= 5000
76        });
77        Self {
78            search_mode: SearchMode::Unknown,
79            search_term: None,
80            search_idx: BTreeSet::new(),
81            search_mark: 0,
82            incremental_search_condition,
83            smart_case: false,
84        }
85    }
86}
87
88#[derive(Debug, Copy, Clone, PartialEq, Eq)]
89pub struct Selection {
90    pub absolute_row: usize,
91    pub col: usize,
92}
93
94/// Holds all information and configuration about the pager during
95/// its run time.
96///
97/// This type is exposed so that end-applications can implement the
98/// [`InputClassifier`](input::InputClassifier) trait which requires the `PagerState` to be passed
99/// as a parameter
100///
101/// Various fields are made public so that their values can be accessed while implementing the
102/// trait.
103#[cfg(feature = "clipboard")]
104#[cfg_attr(docsrs, cfg(feature = "clipboard"))]
105pub type ClipboardHandler = Box<dyn Fn(&str) + Send + Sync + 'static>;
106
107#[derive(Clone, Debug)]
108pub(crate) struct HelpState {
109    pub(crate) screen: Screen,
110    pub(crate) upper_mark: usize,
111    pub(crate) left_mark: usize,
112    pub(crate) prompt: String,
113    pub(crate) follow_output: bool,
114    pub(crate) line_numbers: LineNumbers,
115}
116#[allow(clippy::module_name_repetitions)]
117pub struct PagerState {
118    /// Configuration for line numbers. See [`LineNumbers`]
119    pub line_numbers: LineNumbers,
120    /// Any message to display to the user at the prompt
121    /// The first element contains the actual message, while the second element tells
122    /// whether the message has changed since the last display.
123    pub message: Option<String>,
124    /// The upper bound of scrolling.
125    ///
126    /// This is useful for keeping track of the range of lines which are currently being displayed on
127    /// the terminal.
128    /// When `rows - 1` is added to the `upper_mark`, it gives the lower bound of scroll.
129    ///
130    /// For example if there are 10 rows is a terminal and the data to display has 50 lines in it
131    /// If the `upper_mark` is 15, then the first row of the terminal is the 16th line of the data
132    /// and last row is the 24th line of the data.
133    pub upper_mark: usize,
134    /// The left mark of scrolling
135    ///
136    /// When this is `> 0`, this amount of text will be truncated from the left side
137    pub left_mark: usize,
138    /// Direction of search
139    ///
140    /// See [`SearchMode`] for available options
141    ///
142    /// **WARNING: This item has been deprecated in favour of [`SearchState::search_mode`] availlable
143    /// by the [`PagerState::search_state`] field. Any new code should prefer using it instead of this one.**
144    #[cfg(feature = "search")]
145    #[cfg_attr(docsrs, cfg(feature = "search"))]
146    pub search_mode: SearchMode,
147    /// Available rows in the terminal
148    pub rows: usize,
149    /// Available columns in the terminal
150    pub cols: usize,
151    /// This variable helps in scrolling more than one line at a time
152    /// It keeps track of all the numbers that have been entered by the user
153    /// until any of `j`, `k`, `G`, `Up` or `Down` is pressed
154    pub prefix_num: String,
155    /// Describes whether minus is running and in which mode
156    pub running: &'static Mutex<crate::RunMode>,
157    #[cfg(feature = "search")]
158    #[cfg_attr(docsrs, cfg(feature = "search"))]
159    pub search_state: SearchState,
160    pub screen: Screen,
161    pub selection: Option<Selection>,
162    /// The prompt displayed at the bottom wrapped to available terminal width
163    pub(crate) prompt: String,
164    /// The input classifier to be called when a input is detected
165    pub(crate) input_classifier: Box<dyn input::InputClassifier + Sync + Send>,
166    /// Callback that writes selected text to the clipboard when set; without
167    /// it, `CopySelection` creates its own `arboard::Clipboard` handle.
168    #[cfg(feature = "clipboard")]
169    pub(crate) clipboard_handler: Option<ClipboardHandler>,
170    /// Functions to run when the pager quits
171    pub(crate) exit_callbacks: Vec<Box<dyn FnMut() + Send + Sync + 'static>>,
172    /// Callbacks for hooks
173    pub(crate) hooks: Hooks,
174    /// The prompt that should be displayed to the user, formatted with the
175    /// current search index and number of matches (if the search feature is enabled),
176    /// and the current numbers inputted to scroll
177    pub(crate) displayed_prompt: String,
178    /// Whether to show the prompt on the screen
179    pub(crate) show_prompt: bool,
180    /// Do we want to page if there is no overflow
181    #[cfg(feature = "static_output")]
182    pub(crate) run_no_overflow: bool,
183    pub(crate) lines_to_row_map: LinesRowMap,
184    /// Value for follow mode.
185    /// See [`follow_output`](crate::pager::Pager::follow_output) for more info on follow mode.
186    pub(crate) follow_output: bool,
187    pub(crate) selection_anchor: Option<Selection>,
188    /// Saved state while help screen is active.
189    pub(crate) help_state: Option<HelpState>,
190    /// The output sink configured for the pager.
191    pub output_sink: Arc<Mutex<Box<dyn OutputSink>>>,
192}
193
194impl PagerState {
195    pub(crate) fn new() -> Result<Self, TermError> {
196        #[cfg(not(test))]
197        let default_sink: Box<dyn OutputSink> = Box::new(std::io::stdout());
198        #[cfg(test)]
199        let default_sink: Box<dyn OutputSink> = Box::new(Vec::new());
200
201        let output_sink = Arc::new(Mutex::new(default_sink));
202        let is_tty = output_sink.lock().is_tty();
203
204        let (cols, rows) = if cfg!(test) {
205            // In tests, set  number of columns to 80 and rows to 10
206            (80, 10)
207        } else if is_tty {
208            // If a proper terminal is present, get size and set it
209            let size = terminal::size()?;
210            (size.0 as usize, size.1 as usize)
211        } else {
212            // For other cases beyond control
213            (1, 1)
214        };
215
216        let prompt = std::env::current_exe()
217            .unwrap_or_else(|_| std::path::PathBuf::from("minus"))
218            .file_name()
219            .map_or_else(
220                || std::ffi::OsString::from("minus"),
221                std::ffi::OsStr::to_os_string,
222            )
223            .into_string()
224            .unwrap_or_else(|_| String::from("minus"));
225
226        let mut state = Self {
227            line_numbers: LineNumbers::Disabled,
228            upper_mark: 0,
229            prompt,
230            running: &minus_core::RUNMODE,
231            left_mark: 0,
232            input_classifier: Box::<HashedEventRegister<RandomState>>::default(),
233            #[cfg(feature = "clipboard")]
234            clipboard_handler: None,
235            exit_callbacks: Vec::with_capacity(5),
236            hooks: Hooks::new(),
237            message: None,
238            screen: Screen::default(),
239            selection: None,
240            displayed_prompt: String::new(),
241            show_prompt: true,
242            #[cfg(feature = "static_output")]
243            run_no_overflow: false,
244            #[cfg(feature = "search")]
245            search_mode: SearchMode::default(),
246            #[cfg(feature = "search")]
247            search_state: SearchState::default(),
248            // Just to be safe in tests, keep at 1x1 size
249            cols,
250            rows,
251            prefix_num: String::new(),
252            lines_to_row_map: LinesRowMap::new(),
253            follow_output: false,
254            selection_anchor: None,
255            help_state: None,
256            output_sink,
257        };
258
259        state.hooks.add_callback(
260            Hook::PostPagerExit,
261            1,
262            Box::new(|_| {
263                std::process::exit(0);
264            }),
265        );
266
267        state.format_prompt();
268        Ok(state)
269    }
270
271    /// Generate the initial [`PagerState`]
272    ///
273    /// [`init_core`](crate::minus_core::init::init_core) calls this functions for creating the
274    /// `PagerState`.
275    ///
276    /// This function creates a default [`PagerState`] and fetches all events present in the receiver
277    /// to create the initial state. This is done before starting the pager so that
278    /// the optimizationss can be applied.
279    ///
280    /// # Errors
281    /// This function will return an error if it could not create the default [`PagerState`] or fails
282    /// to process the events
283    pub(crate) fn generate_initial_state(rx: &Receiver<Command>) -> Result<Self, MinusError> {
284        let mut ps = Self::new()?;
285        let mut command_queue = CommandQueue::new_zero();
286        rx.try_iter().for_each(|ev| {
287            handle_event(
288                ev,
289                &mut ps,
290                &mut command_queue,
291                &Arc::new(AtomicBool::new(false)),
292            );
293        });
294        Ok(ps)
295    }
296
297    pub(crate) fn reformat_display(&mut self) {
298        let format_result = screen::format_lines_into(
299            &mut self.screen.formatted_lines,
300            &self.screen.orig_text,
301            self.line_numbers,
302            self.cols,
303            self.screen.line_wrapping,
304            #[cfg(feature = "search")]
305            self.search_state.search_term.as_ref(),
306        );
307
308        #[cfg(feature = "search")]
309        {
310            self.search_state.search_idx = format_result.append_search_idx;
311            self.search_state.search_mark =
312                next_nth_match(&self.search_state.search_idx, self.upper_mark, 0).unwrap_or(0);
313        }
314        self.lines_to_row_map = format_result.lines_to_row_map;
315        self.screen.max_line_length = format_result.max_line_length;
316
317        self.screen.unterminated = format_result.num_unterminated;
318        self.format_prompt();
319    }
320
321    /// Reformat the inputted prompt to how it should be displayed
322    pub(crate) fn format_prompt(&mut self) {
323        const PROMPT_SPEC: &str = "\x1b[2;40;37m";
324        const SEARCH_SPEC: &str = "\x1b[30;44m";
325        const INPUT_SPEC: &str = "\x1b[30;43m";
326        const MSG_SPEC: &str = "\x1b[30;1;41m";
327        const RESET: &str = "\x1b[0m";
328        const FOLLOW_MODE_SPEC: &str = "\x1b[1m";
329
330        // Allocate the string. Add extra space in case for the
331        // ANSI escape things if we do have characters typed and search showing
332        let mut format_string = String::with_capacity(self.cols + (SEARCH_SPEC.len() * 5) + 4);
333
334        // Get the string that will contain the search index/match indicator
335        #[cfg(feature = "search")]
336        let mut search_str = String::new();
337        #[cfg(feature = "search")]
338        if !self.search_state.search_idx.is_empty() {
339            search_str.push(' ');
340            search_str.push_str(&(self.search_state.search_mark + 1).to_string());
341            search_str.push('/');
342            search_str.push_str(&self.search_state.search_idx.len().to_string());
343            search_str.push(' ');
344        }
345
346        // And get the string that will contain the prefix_num
347        let mut prefix_str = String::new();
348        if !self.prefix_num.is_empty() {
349            prefix_str.push(' ');
350            prefix_str.push_str(&self.prefix_num);
351            prefix_str.push(' ');
352        }
353
354        // And lastly, the string that contains the prompt or msg
355        let prompt_str = self.message.as_ref().unwrap_or(&self.prompt);
356
357        #[cfg(feature = "search")]
358        let search_len = search_str.len();
359        #[cfg(not(feature = "search"))]
360        let search_len = 0;
361
362        let follow_mode_str: &str = if self.follow_output { "[F]" } else { "" };
363
364        // Calculate how much extra padding in the middle we need between
365        // the prompt/message and the indicators on the right
366        // NOTE: Count chars of prompt_str as they can be non-ASCII
367        let prefix_len = prefix_str.len();
368        let indicators_len = search_len + prefix_len + follow_mode_str.len();
369        let available_space = self.cols.saturating_sub(indicators_len);
370        let extra_space = available_space.saturating_sub(prompt_str.chars().count());
371
372        let byte_idx = prompt_str.char_indices().nth(available_space);
373
374        // The if-case is especially frequent under non-tty conditions
375        let dsp_prompt: &str = if extra_space == 0
376            && let Some((idx, _)) = byte_idx
377        {
378            &prompt_str[..idx]
379        } else {
380            prompt_str
381        };
382
383        // push the prompt/msg
384        if self.message.is_some() {
385            format_string.push_str(MSG_SPEC);
386        } else {
387            format_string.push_str(PROMPT_SPEC);
388        }
389        format_string.push_str(dsp_prompt);
390        format_string.push_str(&" ".repeat(extra_space));
391
392        // add the prefix_num if it exists
393        if prefix_len > 0 {
394            format_string.push_str(INPUT_SPEC);
395            format_string.push_str(&prefix_str);
396        }
397
398        // and add the search indicator stuff if it exists
399        #[cfg(feature = "search")]
400        if search_len > 0 {
401            format_string.push_str(SEARCH_SPEC);
402            format_string.push_str(&search_str);
403        }
404
405        // add follow-mode indicator
406        if !follow_mode_str.is_empty() {
407            format_string.push_str(FOLLOW_MODE_SPEC);
408            format_string.push_str(follow_mode_str);
409        }
410
411        format_string.push_str(RESET);
412
413        self.displayed_prompt = format_string;
414    }
415
416    /// Enter help mode, displaying the help table screen.
417    pub(crate) fn show_help(&mut self) {
418        if self.help_state.is_some() {
419            return;
420        }
421        let help_text = self
422            .input_classifier
423            .format_help()
424            .unwrap_or_default();
425
426        let saved = HelpState {
427            screen: std::mem::take(&mut self.screen),
428            upper_mark: self.upper_mark,
429            left_mark: self.left_mark,
430            prompt: std::mem::take(&mut self.prompt),
431            follow_output: self.follow_output,
432            line_numbers: self.line_numbers,
433        };
434
435        self.screen = Screen::default();
436        self.screen.orig_text = help_text;
437        self.screen.line_count = self.screen.orig_text.lines().count();
438        self.screen.line_wrapping = false;
439        self.upper_mark = 0;
440        self.left_mark = 0;
441        self.follow_output = false;
442        self.line_numbers = LineNumbers::Disabled;
443        self.prompt = "HELP -- Press q to return to pager".to_string();
444        self.message = None;
445        self.help_state = Some(saved);
446        self.reformat_display();
447    }
448
449    /// Exit help mode, restoring the original document and scroll position.
450    pub(crate) fn exit_help(&mut self) {
451        if let Some(saved) = self.help_state.take() {
452            self.screen = saved.screen;
453            self.upper_mark = saved.upper_mark;
454            self.left_mark = saved.left_mark;
455            self.prompt = saved.prompt;
456            self.follow_output = saved.follow_output;
457            self.line_numbers = saved.line_numbers;
458            self.message = None;
459            self.reformat_display();
460        }
461    }
462
463    pub(crate) fn run_hooks(&mut self, hook: crate::hooks::Hook) {
464        let mut hooks = std::mem::take(&mut self.hooks);
465        hooks.run_hooks(hook, self);
466        self.hooks = hooks;
467    }
468
469    /// Runs the exit callbacks
470    pub(crate) fn exit(&mut self) {
471        for func in &mut self.exit_callbacks {
472            func();
473        }
474    }
475
476    #[cfg(feature = "search")]
477    #[cfg_attr(docsrs, doc(cfg(feature = "search")))]
478    /// Enable or disable smart case searching.
479    ///
480    /// When enabled, search queries containing no uppercase characters are case-insensitive,
481    /// while queries containing uppercase characters remain case-sensitive.
482    pub const fn set_smart_case(&mut self, smart_case: bool) {
483        self.search_state.smart_case = smart_case;
484    }
485
486    pub(crate) fn selection_from_coordinates(&self, x: u16, y: u16) -> Option<Selection> {
487        let writable_rows = self.rows.saturating_sub(1);
488        let row_count = self.screen.formatted_lines_count();
489
490        if row_count == 0 || usize::from(y) >= writable_rows {
491            return None;
492        }
493
494        let absolute_row = self
495            .upper_mark
496            .saturating_add(usize::from(y))
497            .min(row_count - 1);
498        let mut col = usize::from(x).saturating_sub(self.line_number_padding());
499        if !self.screen.line_wrapping {
500            col = col.saturating_add(self.left_mark);
501        }
502
503        Some(Selection { absolute_row, col })
504    }
505
506    pub(crate) const fn clear_selection(&mut self) {
507        self.selection = None;
508        self.selection_anchor = None;
509    }
510
511    pub(crate) fn extract_selection(&self) -> Option<String> {
512        let (start, end) = self.normalized_selection()?;
513        let lines = self.screen.orig_text.lines().collect::<Vec<_>>();
514        let start_line = self.lines_to_row_map.row_to_line(start.absolute_row)?;
515        let end_line = self.lines_to_row_map.row_to_line(end.absolute_row)?;
516
517        let mut selected = Vec::with_capacity(end_line.saturating_sub(start_line) + 1);
518        for line_idx in start_line..=end_line {
519            let raw_line = *lines.get(line_idx)?;
520            let line = strip_ansi(raw_line);
521            let line_len = line.chars().count();
522            let start_col = if line_idx == start_line {
523                self.selection_col_in_line(start, line_idx, &line)
524                    .min(line_len)
525            } else {
526                0
527            };
528            let end_col = if line_idx == end_line {
529                self.selection_col_in_line(end, line_idx, &line)
530                    .saturating_add(1)
531                    .min(line_len)
532            } else {
533                line_len
534            };
535
536            selected.push(slice_chars(&line, start_col, end_col).to_string());
537        }
538
539        Some(selected.join("\n"))
540    }
541
542    pub(crate) fn render_rows_for_display(&self, start: usize, end: usize) -> Vec<Cow<'_, str>> {
543        (start..end)
544            .filter_map(|absolute_row| self.render_row_for_display(absolute_row))
545            .collect()
546    }
547
548    fn render_row_for_display(&self, absolute_row: usize) -> Option<Cow<'_, str>> {
549        let raw_row = self.screen.formatted_lines.get(absolute_row)?;
550        let Some((start_col, end_col)) = self.selection_bounds_for_row(absolute_row) else {
551            return Some(if self.screen.line_wrapping {
552                raw_row.into()
553            } else {
554                self.crop_row_for_horizontal_scroll(raw_row)
555            });
556        };
557
558        let prefix_width = self.line_number_padding();
559        if self.screen.line_wrapping {
560            return Some(highlight_visible_range(
561                Cow::Borrowed(raw_row),
562                prefix_width.saturating_add(start_col),
563                prefix_width.saturating_add(end_col),
564            ));
565        }
566
567        let row = self.crop_row_for_horizontal_scroll(raw_row);
568        let visible_start = start_col.saturating_sub(self.left_mark);
569        let visible_end = end_col.saturating_sub(self.left_mark);
570        Some(highlight_visible_range(
571            row,
572            prefix_width.saturating_add(visible_start),
573            prefix_width.saturating_add(visible_end),
574        ))
575    }
576
577    fn crop_row_for_horizontal_scroll<'a>(&self, row: &'a str) -> Cow<'a, str> {
578        let (first_end, second_start, second_end) = display::get_horizontal_scroll_bounds(
579            row,
580            self.cols,
581            self.left_mark,
582            self.line_numbers.is_on(),
583            self.screen.line_count(),
584        );
585
586        if self.left_mark < row.len() {
587            if self.line_numbers.is_on() {
588                format!("{}{}", &row[..first_end], &row[second_start..second_end]).into()
589            } else {
590                row[second_start..second_end].into()
591            }
592        } else {
593            Cow::Borrowed("")
594        }
595    }
596
597    const fn line_number_padding(&self) -> usize {
598        if self.line_numbers.is_on() {
599            minus_core::utils::digits(self.screen.line_count()) + LineNumbers::EXTRA_PADDING + 2
600        } else {
601            0
602        }
603    }
604
605    fn selection_bounds_for_row(&self, absolute_row: usize) -> Option<(usize, usize)> {
606        let (start, end) = self.normalized_selection()?;
607
608        if absolute_row < start.absolute_row || absolute_row > end.absolute_row {
609            return None;
610        }
611
612        let start_col = if absolute_row == start.absolute_row {
613            start.col
614        } else {
615            0
616        };
617        let end_col = if absolute_row == end.absolute_row {
618            end.col.saturating_add(1)
619        } else {
620            usize::MAX
621        };
622        Some((start_col, end_col))
623    }
624
625    fn normalized_selection(&self) -> Option<(Selection, Selection)> {
626        let s_start = self.selection_anchor?;
627        let s_end = self.selection?;
628
629        Some(
630            if s_start.absolute_row > s_end.absolute_row
631                || (s_start.absolute_row == s_end.absolute_row && s_start.col > s_end.col)
632            {
633                (s_end, s_start)
634            } else {
635                (s_start, s_end)
636            },
637        )
638    }
639
640    fn selection_col_in_line(&self, selection: Selection, line_idx: usize, line: &str) -> usize {
641        if !self.screen.line_wrapping {
642            return selection.col;
643        }
644
645        let Some(&line_start_row) = self.lines_to_row_map.get(line_idx) else {
646            return selection.col;
647        };
648        let row_in_line = selection.absolute_row.saturating_sub(line_start_row);
649        let cols_avail = self.wrapped_cols_available();
650        let wrapped_rows = textwrap::wrap(line, cols_avail.max(1));
651        let preceding_chars = wrapped_rows
652            .iter()
653            .take(row_in_line)
654            .map(|row| row.chars().count())
655            .sum::<usize>();
656
657        preceding_chars.saturating_add(selection.col)
658    }
659
660    const fn wrapped_cols_available(&self) -> usize {
661        if self.line_numbers.is_on() {
662            let padding = minus_core::utils::digits(self.screen.line_count())
663                + LineNumbers::EXTRA_PADDING
664                + 1;
665            self.cols.saturating_sub(padding + 2)
666        } else {
667            self.cols
668        }
669    }
670
671    pub(crate) fn append_str(&mut self, text: &str) -> AppendStyle {
672        let old_lc = self.screen.line_count();
673        let old_lc_dgts = minus_core::utils::digits(old_lc);
674        let mut append_result = self.screen.push_screen_buf(
675            text,
676            self.line_numbers,
677            self.cols.try_into().unwrap(),
678            #[cfg(feature = "search")]
679            self.search_state.search_term.as_ref(),
680        );
681        let new_lc = self.screen.line_count();
682        let new_lc_dgts = minus_core::utils::digits(new_lc);
683        #[cfg(feature = "search")]
684        {
685            let mut append_search_idx = append_result.append_search_idx;
686            self.search_state.search_idx.append(&mut append_search_idx);
687        }
688        self.lines_to_row_map.append(
689            &mut append_result.lines_to_row_map,
690            append_result.clean_append,
691        );
692
693        if self.line_numbers.is_on() && (new_lc_dgts != old_lc_dgts && old_lc_dgts != 0) {
694            self.reformat_display();
695            return AppendStyle::FullRedraw;
696        }
697
698        let total_rows = self.screen.formatted_lines_count();
699        AppendStyle::PartialUpdate((total_rows - append_result.rows_formatted, total_rows))
700    }
701}
702
703fn slice_chars(line: &str, start: usize, end: usize) -> &str {
704    let mut indices = line
705        .char_indices()
706        .map(|(idx, _)| idx)
707        .chain(std::iter::once(line.len()));
708    let start_byte = indices.nth(start).unwrap_or(line.len());
709    let end_byte = indices
710        .nth(end.saturating_sub(start + 1))
711        .unwrap_or(line.len());
712
713    &line[start_byte..end_byte]
714}
715
716fn highlight_visible_range(line: Cow<str>, start: usize, end: usize) -> Cow<str> {
717    const REVERSE: &str = "\x1b[7m";
718    const RESET: &str = "\x1b[27m";
719
720    if start >= end {
721        return line;
722    }
723
724    let bytes = line.as_bytes();
725    let mut out = String::with_capacity(line.len() + REVERSE.len() + RESET.len());
726    let mut byte_idx = 0;
727    let mut visible_idx = 0;
728    let mut highlighted = false;
729
730    while byte_idx < bytes.len() {
731        if highlighted && visible_idx == end {
732            out.push_str(RESET);
733            highlighted = false;
734        }
735        if !highlighted && visible_idx == start {
736            out.push_str(REVERSE);
737            highlighted = true;
738        }
739
740        if bytes[byte_idx] == b'\x1b' && bytes.get(byte_idx + 1) == Some(&b'[') {
741            let esc_start = byte_idx;
742            byte_idx += 2;
743            let mut final_byte = 0;
744            while byte_idx < bytes.len() {
745                let byte = bytes[byte_idx];
746                byte_idx += 1;
747                if (0x40..=0x7e).contains(&byte) {
748                    final_byte = byte;
749                    break;
750                }
751            }
752            out.push_str(&line[esc_start..byte_idx]);
753            if highlighted && final_byte == b'm' {
754                out.push_str(REVERSE);
755            }
756            continue;
757        }
758
759        let ch = line[byte_idx..].chars().next().unwrap();
760        out.push(ch);
761        visible_idx += 1;
762        byte_idx += ch.len_utf8();
763    }
764
765    if highlighted {
766        out.push_str(RESET);
767    }
768
769    out.into()
770}
771
772pub(crate) fn strip_ansi(s: &str) -> String {
773    let mut out = String::with_capacity(s.len());
774    let bytes = s.as_bytes();
775    let mut i = 0;
776    while i < bytes.len() {
777        if bytes[i] == b'\x1b' {
778            if i + 1 < bytes.len() {
779                match bytes[i + 1] {
780                    b'[' => {
781                        i += 2;
782                        while i < bytes.len() {
783                            let b = bytes[i];
784                            i += 1;
785                            if (0x40..=0x7e).contains(&b) {
786                                break;
787                            }
788                        }
789                    }
790                    b']' => {
791                        i += 2;
792                        while i < bytes.len() {
793                            if bytes[i] == 0x07 {
794                                i += 1;
795                                break;
796                            }
797                            if bytes[i] == b'\x1b' && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
798                                i += 2;
799                                break;
800                            }
801                            i += 1;
802                        }
803                    }
804                    _ => {
805                        i += 2;
806                    }
807                }
808            } else {
809                i += 1;
810            }
811        } else if bytes[i] == 0x9b {
812            i += 1;
813            while i < bytes.len() {
814                let b = bytes[i];
815                i += 1;
816                if (0x40..=0x7e).contains(&b) {
817                    break;
818                }
819            }
820        } else {
821            let ch = s[i..].chars().next().unwrap();
822            out.push(ch);
823            i += ch.len_utf8();
824        }
825    }
826    out
827}
828
829#[cfg(test)]
830mod tests {
831    use super::{PagerState, Selection, highlight_visible_range, strip_ansi};
832    use crate::LineNumbers;
833
834    #[test]
835    #[allow(clippy::cast_possible_truncation)]
836    fn extract_selection_uses_logical_text_coordinates() {
837        let mut ps = PagerState::new().unwrap();
838        ps.line_numbers = LineNumbers::Enabled;
839        ps.screen.line_wrapping = false;
840        ps.left_mark = 3;
841        ps.screen.orig_text = "abcdefghij\nklmnopqrst\nuvwxyz\n".to_string();
842        ps.reformat_display();
843
844        let padding = ps.line_number_padding() as u16;
845        ps.selection_anchor = ps.selection_from_coordinates(padding + 1, 0);
846        ps.selection = ps.selection_from_coordinates(padding + 1, 2);
847
848        assert_eq!(
849            ps.extract_selection().as_deref(),
850            Some("efghij\nklmnopqrst\nuvwxy")
851        );
852    }
853
854    #[test]
855    fn extract_selection_with_ansi_styles() {
856        let mut ps = PagerState::new().unwrap();
857        ps.line_numbers = LineNumbers::Disabled;
858        ps.screen.line_wrapping = false;
859        ps.screen.orig_text =
860            "\x1b[31mhello\x1b[0m \x1b[1;32mworld\x1b[0m\n\x1b[34msecond\x1b[0m line\n".to_string();
861        ps.reformat_display();
862
863        // Select "hello world" from line 0
864        ps.selection_anchor = ps.selection_from_coordinates(0, 0);
865        ps.selection = ps.selection_from_coordinates(10, 0);
866        assert_eq!(ps.extract_selection().as_deref(), Some("hello world"));
867
868        // Select "world" from line 0
869        ps.selection_anchor = ps.selection_from_coordinates(6, 0);
870        ps.selection = ps.selection_from_coordinates(10, 0);
871        assert_eq!(ps.extract_selection().as_deref(), Some("world"));
872    }
873
874    #[test]
875    fn test_strip_ansi() {
876        assert_eq!(strip_ansi(""), "");
877        assert_eq!(strip_ansi("plain text"), "plain text");
878        assert_eq!(strip_ansi("\x1b[31mhello\x1b[0m"), "hello");
879        assert_eq!(strip_ansi("\x1b[1;38;2;255;0;0mRGB\x1b[0m text"), "RGB text");
880        assert_eq!(strip_ansi("\x1b]8;;https://example.com\x07link\x1b]8;;\x07"), "link");
881        assert_eq!(strip_ansi("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"), "link");
882    }
883
884    #[test]
885    fn test_highlight_visible_range_with_ansi_styles() {
886        use std::borrow::Cow;
887        // Selection across style reset and color change: "hello world"
888        let line = Cow::Borrowed("\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m");
889        let highlighted = highlight_visible_range(line, 0, 11);
890        // Highlight should be active for "hello", re-asserted after \x1b[0m and \x1b[32m, and reset after 11
891        assert_eq!(
892            highlighted.as_ref(),
893            "\x1b[7m\x1b[31m\x1b[7mhello\x1b[0m\x1b[7m \x1b[32m\x1b[7mworld\x1b[27m\x1b[0m"
894        );
895    }
896
897    #[test]
898    fn extract_selection_across_wrapped_rows() {
899        let mut ps = PagerState::new().unwrap();
900        ps.cols = 6;
901        ps.screen.orig_text = "abcdefghi\njklmnop\n".to_string();
902        ps.reformat_display();
903        ps.selection_anchor = Some(Selection {
904            absolute_row: 0,
905            col: 2,
906        });
907        ps.selection = Some(Selection {
908            absolute_row: 2,
909            col: 3,
910        });
911
912        assert_eq!(ps.extract_selection().as_deref(), Some("cdefghi\njklm"));
913    }
914
915    #[test]
916    fn format_prompt_truncates_long_message_to_available_width() {
917        let mut ps = PagerState::new().unwrap();
918        ps.cols = 20;
919        let long_msg = "Help: q:quit | j/k:scroll | Space:page";
920        ps.message = Some(long_msg.to_string());
921        ps.format_prompt();
922
923        // Should truncate message to fit 20 cols
924        assert!(ps.displayed_prompt.contains(&long_msg[..20]));
925
926        // With follow mode [F] (3 chars), prompt should truncate to 17 chars
927        ps.follow_output = true;
928        ps.format_prompt();
929        assert!(ps.displayed_prompt.contains(&long_msg[..17]));
930        assert!(ps.displayed_prompt.contains("[F]"));
931    }
932}