1#![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)]
38pub struct SearchState {
40 pub search_mode: SearchMode,
44 pub(crate) search_term: Option<regex::Regex>,
46 pub(crate) search_idx: BTreeSet<usize>,
49 pub(crate) search_mark: usize,
52 pub(crate) incremental_search_condition:
56 Box<dyn Fn(&SearchOpts) -> bool + Send + Sync + 'static>,
57 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#[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 pub line_numbers: LineNumbers,
120 pub message: Option<String>,
124 pub upper_mark: usize,
134 pub left_mark: usize,
138 #[cfg(feature = "search")]
145 #[cfg_attr(docsrs, cfg(feature = "search"))]
146 pub search_mode: SearchMode,
147 pub rows: usize,
149 pub cols: usize,
151 pub prefix_num: String,
155 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 pub(crate) prompt: String,
164 pub(crate) input_classifier: Box<dyn input::InputClassifier + Sync + Send>,
166 #[cfg(feature = "clipboard")]
169 pub(crate) clipboard_handler: Option<ClipboardHandler>,
170 pub(crate) exit_callbacks: Vec<Box<dyn FnMut() + Send + Sync + 'static>>,
172 pub(crate) hooks: Hooks,
174 pub(crate) displayed_prompt: String,
178 pub(crate) show_prompt: bool,
180 #[cfg(feature = "static_output")]
182 pub(crate) run_no_overflow: bool,
183 pub(crate) lines_to_row_map: LinesRowMap,
184 pub(crate) follow_output: bool,
187 pub(crate) selection_anchor: Option<Selection>,
188 pub(crate) help_state: Option<HelpState>,
190 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 (80, 10)
207 } else if is_tty {
208 let size = terminal::size()?;
210 (size.0 as usize, size.1 as usize)
211 } else {
212 (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 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 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 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 let mut format_string = String::with_capacity(self.cols + (SEARCH_SPEC.len() * 5) + 4);
333
334 #[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 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 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 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 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 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 if prefix_len > 0 {
394 format_string.push_str(INPUT_SPEC);
395 format_string.push_str(&prefix_str);
396 }
397
398 #[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 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 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 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 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 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 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 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 let line = Cow::Borrowed("\x1b[31mhello\x1b[0m \x1b[32mworld\x1b[0m");
889 let highlighted = highlight_visible_range(line, 0, 11);
890 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 assert!(ps.displayed_prompt.contains(&long_msg[..20]));
925
926 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}