[go: up one dir, main page]

uu_more/
more.rs

1// This file is part of the uutils coreutils package.
2//
3// For the full copyright and license information, please view the LICENSE
4// file that was distributed with this source code.
5
6use std::{
7    ffi::OsString,
8    fs::File,
9    io::{BufRead, BufReader, Stdin, Stdout, Write, stdin, stdout},
10    panic::set_hook,
11    path::Path,
12    time::Duration,
13};
14
15use clap::{Arg, ArgAction, ArgMatches, Command, value_parser};
16use crossterm::{
17    ExecutableCommand,
18    QueueableCommand, // spell-checker:disable-line
19    cursor::{Hide, MoveTo, Show},
20    event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
21    style::Attribute,
22    terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
23    tty::IsTty,
24};
25
26use uucore::error::{UResult, USimpleError, UUsageError};
27use uucore::format_usage;
28use uucore::{display::Quotable, show};
29
30use uucore::translate;
31
32#[derive(Debug)]
33enum MoreError {
34    IsDirectory(String),
35    CannotOpenNoSuchFile(String),
36    CannotOpenIOError(String, std::io::ErrorKind),
37    BadUsage,
38}
39
40impl std::fmt::Display for MoreError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::IsDirectory(path) => {
44                write!(
45                    f,
46                    "{}",
47                    translate!(
48                        "more-error-is-directory",
49                        "path" => path.quote()
50                    )
51                )
52            }
53            Self::CannotOpenNoSuchFile(path) => {
54                write!(
55                    f,
56                    "{}",
57                    translate!(
58                        "more-error-cannot-open-no-such-file",
59                        "path" => path.quote()
60                    )
61                )
62            }
63            Self::CannotOpenIOError(path, error) => {
64                write!(
65                    f,
66                    "{}",
67                    translate!(
68                    "more-error-cannot-open-io-error",
69                    "path" => path.quote(),
70                    "error" => error
71                    )
72                )
73            }
74            Self::BadUsage => {
75                write!(f, "{}", translate!("more-error-bad-usage"))
76            }
77        }
78    }
79}
80
81impl std::error::Error for MoreError {}
82
83const BELL: char = '\x07'; // Printing this character will ring the bell
84
85// The prompt to be displayed at the top of the screen when viewing multiple files,
86// with the file name in the middle
87const MULTI_FILE_TOP_PROMPT: &str = "\r::::::::::::::\n\r{}\n\r::::::::::::::\n";
88
89pub mod options {
90    pub const SILENT: &str = "silent";
91    pub const LOGICAL: &str = "logical";
92    pub const EXIT_ON_EOF: &str = "exit-on-eof";
93    pub const NO_PAUSE: &str = "no-pause";
94    pub const PRINT_OVER: &str = "print-over";
95    pub const CLEAN_PRINT: &str = "clean-print";
96    pub const SQUEEZE: &str = "squeeze";
97    pub const PLAIN: &str = "plain";
98    pub const LINES: &str = "lines";
99    pub const NUMBER: &str = "number";
100    pub const PATTERN: &str = "pattern";
101    pub const FROM_LINE: &str = "from-line";
102    pub const FILES: &str = "files";
103}
104
105struct Options {
106    silent: bool,
107    _logical: bool,     // not implemented
108    _exit_on_eof: bool, // not implemented
109    _no_pause: bool,    // not implemented
110    print_over: bool,
111    clean_print: bool,
112    squeeze: bool,
113    lines: Option<u16>,
114    from_line: usize,
115    pattern: Option<String>,
116}
117
118impl Options {
119    fn from(matches: &ArgMatches) -> Self {
120        let lines = match (
121            matches.get_one::<u16>(options::LINES).copied(),
122            matches.get_one::<u16>(options::NUMBER).copied(),
123        ) {
124            // We add 1 to the number of lines to display because the last line
125            // is used for the banner
126            (Some(n), _) | (None, Some(n)) if n > 0 => Some(n + 1),
127            _ => None, // Use terminal height
128        };
129        let from_line = match matches.get_one::<usize>(options::FROM_LINE).copied() {
130            Some(number) => number.saturating_sub(1),
131            _ => 0,
132        };
133        let pattern = matches.get_one::<String>(options::PATTERN).cloned();
134        Self {
135            silent: matches.get_flag(options::SILENT),
136            _logical: matches.get_flag(options::LOGICAL),
137            _exit_on_eof: matches.get_flag(options::EXIT_ON_EOF),
138            _no_pause: matches.get_flag(options::NO_PAUSE),
139            print_over: matches.get_flag(options::PRINT_OVER),
140            clean_print: matches.get_flag(options::CLEAN_PRINT),
141            squeeze: matches.get_flag(options::SQUEEZE),
142            lines,
143            from_line,
144            pattern,
145        }
146    }
147}
148
149#[uucore::main]
150pub fn uumain(args: impl uucore::Args) -> UResult<()> {
151    set_hook(Box::new(|panic_info| {
152        print!("\r");
153        println!("{panic_info}");
154    }));
155    let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?;
156    let mut options = Options::from(&matches);
157    if let Some(files) = matches.get_many::<OsString>(options::FILES) {
158        let length = files.len();
159
160        let mut files_iter = files.peekable();
161        while let (Some(file_os), next_file) = (files_iter.next(), files_iter.peek()) {
162            let file = Path::new(file_os);
163            if file.is_dir() {
164                show!(UUsageError::new(
165                    0,
166                    MoreError::IsDirectory(file.to_string_lossy().to_string()).to_string(),
167                ));
168                continue;
169            }
170            if !file.exists() {
171                show!(USimpleError::new(
172                    0,
173                    MoreError::CannotOpenNoSuchFile(file.to_string_lossy().to_string()).to_string(),
174                ));
175                continue;
176            }
177            let opened_file = match File::open(file) {
178                Err(why) => {
179                    show!(USimpleError::new(
180                        0,
181                        MoreError::CannotOpenIOError(
182                            file.to_string_lossy().to_string(),
183                            why.kind()
184                        )
185                        .to_string(),
186                    ));
187                    continue;
188                }
189                Ok(opened_file) => opened_file,
190            };
191            let next_file_str = next_file.map(|f| f.to_string_lossy().into_owned());
192            more(
193                InputType::File(BufReader::new(opened_file)),
194                length > 1,
195                Some(&file.to_string_lossy()),
196                next_file_str.as_deref(),
197                &mut options,
198            )?;
199        }
200    } else {
201        let stdin = stdin();
202        if stdin.is_tty() {
203            // stdin is not a pipe
204            return Err(UUsageError::new(1, MoreError::BadUsage.to_string()));
205        }
206        more(InputType::Stdin(stdin), false, None, None, &mut options)?;
207    }
208
209    Ok(())
210}
211
212pub fn uu_app() -> Command {
213    Command::new(uucore::util_name())
214        .about(translate!("more-about"))
215        .override_usage(format_usage(&translate!("more-usage")))
216        .version(uucore::crate_version!())
217        .help_template(uucore::localized_help_template(uucore::util_name()))
218        .infer_long_args(true)
219        .arg(
220            Arg::new(options::SILENT)
221                .short('d')
222                .long(options::SILENT)
223                .action(ArgAction::SetTrue)
224                .help(translate!("more-help-silent")),
225        )
226        .arg(
227            Arg::new(options::LOGICAL)
228                .short('l')
229                .long(options::LOGICAL)
230                .action(ArgAction::SetTrue)
231                .help(translate!("more-help-logical")),
232        )
233        .arg(
234            Arg::new(options::EXIT_ON_EOF)
235                .short('e')
236                .long(options::EXIT_ON_EOF)
237                .action(ArgAction::SetTrue)
238                .help(translate!("more-help-exit-on-eof")),
239        )
240        .arg(
241            Arg::new(options::NO_PAUSE)
242                .short('f')
243                .long(options::NO_PAUSE)
244                .action(ArgAction::SetTrue)
245                .help(translate!("more-help-no-pause")),
246        )
247        .arg(
248            Arg::new(options::PRINT_OVER)
249                .short('p')
250                .long(options::PRINT_OVER)
251                .action(ArgAction::SetTrue)
252                .help(translate!("more-help-print-over")),
253        )
254        .arg(
255            Arg::new(options::CLEAN_PRINT)
256                .short('c')
257                .long(options::CLEAN_PRINT)
258                .action(ArgAction::SetTrue)
259                .help(translate!("more-help-clean-print")),
260        )
261        .arg(
262            Arg::new(options::SQUEEZE)
263                .short('s')
264                .long(options::SQUEEZE)
265                .action(ArgAction::SetTrue)
266                .help(translate!("more-help-squeeze")),
267        )
268        .arg(
269            Arg::new(options::PLAIN)
270                .short('u')
271                .long(options::PLAIN)
272                .action(ArgAction::SetTrue)
273                .hide(true)
274                .help(translate!("more-help-plain")),
275        )
276        .arg(
277            Arg::new(options::LINES)
278                .short('n')
279                .long(options::LINES)
280                .value_name("number")
281                .num_args(1)
282                .value_parser(value_parser!(u16).range(0..))
283                .help(translate!("more-help-lines")),
284        )
285        .arg(
286            Arg::new(options::NUMBER)
287                .long(options::NUMBER)
288                .num_args(1)
289                .value_parser(value_parser!(u16).range(0..))
290                .help(translate!("more-help-number")),
291        )
292        .arg(
293            Arg::new(options::FROM_LINE)
294                .short('F')
295                .long(options::FROM_LINE)
296                .num_args(1)
297                .value_name("number")
298                .value_parser(value_parser!(usize))
299                .help(translate!("more-help-from-line")),
300        )
301        .arg(
302            Arg::new(options::PATTERN)
303                .short('P')
304                .long(options::PATTERN)
305                .allow_hyphen_values(true)
306                .required(false)
307                .value_name("pattern")
308                .help(translate!("more-help-pattern")),
309        )
310        .arg(
311            Arg::new(options::FILES)
312                .required(false)
313                .action(ArgAction::Append)
314                .help(translate!("more-help-files"))
315                .value_hint(clap::ValueHint::FilePath)
316                .value_parser(clap::value_parser!(OsString)),
317        )
318}
319
320enum InputType {
321    File(BufReader<File>),
322    Stdin(Stdin),
323}
324
325impl InputType {
326    fn read_line(&mut self, buf: &mut String) -> std::io::Result<usize> {
327        match self {
328            Self::File(reader) => reader.read_line(buf),
329            Self::Stdin(stdin) => stdin.read_line(buf),
330        }
331    }
332
333    fn len(&self) -> std::io::Result<Option<u64>> {
334        let len = match self {
335            Self::File(reader) => Some(reader.get_ref().metadata()?.len()),
336            Self::Stdin(_) => None,
337        };
338        Ok(len)
339    }
340}
341
342enum OutputType {
343    Tty(Stdout),
344    Pipe(Box<dyn Write>),
345    #[cfg(test)]
346    Test(Vec<u8>),
347}
348
349impl IsTty for OutputType {
350    fn is_tty(&self) -> bool {
351        matches!(self, Self::Tty(_))
352    }
353}
354
355impl Write for OutputType {
356    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
357        match self {
358            Self::Tty(stdout) => stdout.write(buf),
359            Self::Pipe(writer) => writer.write(buf),
360            #[cfg(test)]
361            Self::Test(vec) => vec.write(buf),
362        }
363    }
364
365    fn flush(&mut self) -> std::io::Result<()> {
366        match self {
367            Self::Tty(stdout) => stdout.flush(),
368            Self::Pipe(writer) => writer.flush(),
369            #[cfg(test)]
370            Self::Test(vec) => vec.flush(),
371        }
372    }
373}
374
375fn setup_term() -> UResult<OutputType> {
376    let mut stdout = stdout();
377    if stdout.is_tty() {
378        terminal::enable_raw_mode()?;
379        stdout.execute(EnterAlternateScreen)?.execute(Hide)?;
380        Ok(OutputType::Tty(stdout))
381    } else {
382        Ok(OutputType::Pipe(Box::new(stdout)))
383    }
384}
385
386#[cfg(target_os = "fuchsia")]
387#[inline(always)]
388fn setup_term() -> UResult<OutputType> {
389    // no real stdout/tty on Fuchsia, just write into a pipe
390    Ok(OutputType::Pipe(Box::new(stdout())))
391}
392
393fn reset_term() -> UResult<()> {
394    let mut stdout = stdout();
395    if stdout.is_tty() {
396        stdout.queue(Show)?.queue(LeaveAlternateScreen)?;
397        terminal::disable_raw_mode()?;
398    } else {
399        stdout.queue(Clear(ClearType::CurrentLine))?;
400        write!(stdout, "\r")?;
401    }
402    stdout.flush()?;
403    Ok(())
404}
405
406#[cfg(target_os = "fuchsia")]
407#[inline(always)]
408fn reset_term() -> UResult<()> {
409    Ok(())
410}
411
412struct TerminalGuard;
413
414impl Drop for TerminalGuard {
415    fn drop(&mut self) {
416        // Ignore errors in destructor
417        let _ = reset_term();
418    }
419}
420
421fn more(
422    input: InputType,
423    multiple_file: bool,
424    file_name: Option<&str>,
425    next_file: Option<&str>,
426    options: &mut Options,
427) -> UResult<()> {
428    // Initialize output
429    let out = setup_term()?;
430    // Ensure raw mode is disabled on drop
431    let _guard = TerminalGuard;
432    // Create pager
433    let (_cols, mut rows) = terminal::size()?;
434    if let Some(number) = options.lines {
435        rows = number;
436    }
437    let mut pager = Pager::new(input, rows, file_name, next_file, options, out)?;
438    // Start from the specified line
439    pager.handle_from_line()?;
440    // Search for pattern
441    pager.handle_pattern_search()?;
442    // Handle multi-file display header if needed
443    if multiple_file {
444        pager.display_multi_file_header()?;
445    }
446    // Initial display
447    pager.draw(None)?;
448    // Reset multi-file settings after initial display
449    if multiple_file {
450        pager.reset_multi_file_header();
451        options.from_line = 0;
452    }
453    // Main event loop
454    pager.process_events(options)
455}
456
457struct Pager<'a> {
458    /// Source of the content (file, stdin)
459    input: InputType,
460    /// Total size of the file in bytes (only available for file inputs)
461    file_size: Option<u64>,
462    /// Storage for the lines read from the input
463    lines: Vec<String>,
464    /// Running total of byte sizes for each line, used for positioning
465    cumulative_line_sizes: Vec<u64>,
466    /// Index of the line currently displayed at the top of the screen
467    upper_mark: usize,
468    /// Number of rows that can be displayed on the screen at once
469    content_rows: usize,
470    /// Count of blank lines that have been condensed in the current view
471    lines_squeezed: usize,
472    pattern: Option<String>,
473    file_name: Option<&'a str>,
474    next_file: Option<&'a str>,
475    eof_reached: bool,
476    silent: bool,
477    squeeze: bool,
478    stdout: OutputType,
479}
480
481impl<'a> Pager<'a> {
482    fn new(
483        input: InputType,
484        rows: u16,
485        file_name: Option<&'a str>,
486        next_file: Option<&'a str>,
487        options: &Options,
488        stdout: OutputType,
489    ) -> UResult<Self> {
490        // Reserve one line for the status bar, ensuring at least one content row
491        let content_rows = rows.saturating_sub(1).max(1) as usize;
492        let file_size = input.len()?;
493        let pager = Self {
494            input,
495            file_size,
496            lines: Vec::with_capacity(content_rows),
497            cumulative_line_sizes: Vec::new(),
498            upper_mark: options.from_line,
499            content_rows,
500            lines_squeezed: 0,
501            pattern: options.pattern.clone(),
502            file_name,
503            next_file,
504            eof_reached: false,
505            silent: options.silent,
506            squeeze: options.squeeze,
507            stdout,
508        };
509        Ok(pager)
510    }
511
512    fn handle_from_line(&mut self) -> UResult<()> {
513        if !self.read_until_line(self.upper_mark)? {
514            write!(
515                self.stdout,
516                "\r{}{} ({}){}",
517                Attribute::Reverse,
518                translate!(
519                    "more-error-cannot-seek-to-line",
520                    "line" => (self.upper_mark + 1)
521                ),
522                translate!("more-press-return"),
523                Attribute::Reset,
524            )?;
525            self.stdout.flush()?;
526            self.wait_for_enter_key()?;
527            self.upper_mark = 0;
528        }
529        Ok(())
530    }
531
532    fn read_until_line(&mut self, target_line: usize) -> UResult<bool> {
533        // Read lines until we reach the target line or EOF
534        let mut line = String::new();
535        while self.lines.len() <= target_line {
536            let bytes_read = self.input.read_line(&mut line)?;
537            if bytes_read == 0 {
538                return Ok(false); // EOF
539            }
540            // Track cumulative byte position
541            let last_pos = self.cumulative_line_sizes.last().copied().unwrap_or(0);
542            self.cumulative_line_sizes
543                .push(last_pos + bytes_read as u64);
544            // Remove trailing whitespace
545            line = line.trim_end().to_string();
546            // Store the line (using mem::take to avoid clone)
547            self.lines.push(std::mem::take(&mut line));
548        }
549        Ok(true)
550    }
551
552    fn wait_for_enter_key(&self) -> UResult<()> {
553        if !self.stdout.is_tty() {
554            return Ok(());
555        }
556        loop {
557            if event::poll(Duration::from_millis(100))? {
558                if let Event::Key(KeyEvent {
559                    code: KeyCode::Enter,
560                    modifiers: KeyModifiers::NONE,
561                    kind: KeyEventKind::Press,
562                    ..
563                }) = event::read()?
564                {
565                    return Ok(());
566                }
567            }
568        }
569    }
570
571    fn handle_pattern_search(&mut self) -> UResult<()> {
572        if self.pattern.is_none() {
573            return Ok(());
574        }
575        match self.search_pattern_in_file() {
576            Some(line) => self.upper_mark = line,
577            None => {
578                self.pattern = None;
579                write!(
580                    self.stdout,
581                    "\r{}{} ({}){}",
582                    Attribute::Reverse,
583                    translate!("more-error-pattern-not-found"),
584                    translate!("more-press-return"),
585                    Attribute::Reset,
586                )?;
587                self.stdout.flush()?;
588                self.wait_for_enter_key()?;
589            }
590        }
591        Ok(())
592    }
593
594    fn search_pattern_in_file(&mut self) -> Option<usize> {
595        let pattern = self.pattern.clone().expect("pattern should be set");
596        let mut line_num = self.upper_mark;
597        loop {
598            match self.get_line(line_num) {
599                Some(line) if line.contains(&pattern) => return Some(line_num),
600                Some(_) => line_num += 1,
601                None => return None,
602            }
603        }
604    }
605
606    fn get_line(&mut self, index: usize) -> Option<&String> {
607        match self.read_until_line(index) {
608            Ok(true) => self.lines.get(index),
609            _ => None,
610        }
611    }
612
613    fn display_multi_file_header(&mut self) -> UResult<()> {
614        self.stdout.queue(Clear(ClearType::CurrentLine))?;
615        self.stdout.write_all(
616            MULTI_FILE_TOP_PROMPT
617                .replace("{}", self.file_name.unwrap_or_default())
618                .as_bytes(),
619        )?;
620        self.content_rows = self
621            .content_rows
622            .saturating_sub(MULTI_FILE_TOP_PROMPT.lines().count());
623        Ok(())
624    }
625
626    fn reset_multi_file_header(&mut self) {
627        self.content_rows = self
628            .content_rows
629            .saturating_add(MULTI_FILE_TOP_PROMPT.lines().count());
630    }
631
632    fn update_display(&mut self, options: &Options) -> UResult<()> {
633        if options.print_over {
634            self.stdout
635                .execute(MoveTo(0, 0))?
636                .execute(Clear(ClearType::FromCursorDown))?;
637        } else if options.clean_print {
638            self.stdout
639                .execute(Clear(ClearType::All))?
640                .execute(MoveTo(0, 0))?;
641        }
642        Ok(())
643    }
644
645    /// Process user input events until exit
646    fn process_events(&mut self, options: &Options) -> UResult<()> {
647        loop {
648            if !event::poll(Duration::from_millis(100))? {
649                continue;
650            }
651            let mut wrong_key = None;
652            match event::read()? {
653                // --- Quit commands ---
654                Event::Key(
655                    KeyEvent {
656                        code: KeyCode::Char('q'),
657                        modifiers: KeyModifiers::NONE,
658                        kind: KeyEventKind::Press,
659                        ..
660                    }
661                    | KeyEvent {
662                        code: KeyCode::Char('c'),
663                        modifiers: KeyModifiers::CONTROL,
664                        kind: KeyEventKind::Press,
665                        ..
666                    },
667                ) => {
668                    reset_term()?;
669                    std::process::exit(0);
670                }
671
672                // --- Forward Navigation ---
673                Event::Key(KeyEvent {
674                    code: KeyCode::Down | KeyCode::PageDown | KeyCode::Char(' '),
675                    modifiers: KeyModifiers::NONE,
676                    ..
677                }) => {
678                    if self.eof_reached {
679                        return Ok(());
680                    }
681                    self.page_down();
682                }
683                Event::Key(KeyEvent {
684                    code: KeyCode::Enter | KeyCode::Char('j'),
685                    modifiers: KeyModifiers::NONE,
686                    ..
687                }) => {
688                    if self.eof_reached {
689                        return Ok(());
690                    }
691                    self.next_line();
692                }
693
694                // --- Backward Navigation ---
695                Event::Key(KeyEvent {
696                    code: KeyCode::Up | KeyCode::PageUp,
697                    modifiers: KeyModifiers::NONE,
698                    ..
699                }) => {
700                    self.page_up();
701                }
702                Event::Key(KeyEvent {
703                    code: KeyCode::Char('k'),
704                    modifiers: KeyModifiers::NONE,
705                    ..
706                }) => {
707                    self.prev_line();
708                }
709
710                // --- Terminal events ---
711                Event::Resize(col, row) => {
712                    self.page_resize(col, row, options.lines);
713                }
714
715                // --- Skip key release events ---
716                Event::Key(KeyEvent {
717                    kind: KeyEventKind::Release,
718                    ..
719                }) => continue,
720
721                // --- Handle unknown keys ---
722                Event::Key(KeyEvent {
723                    code: KeyCode::Char(k),
724                    ..
725                }) => wrong_key = Some(k),
726
727                // --- Ignore other events ---
728                _ => continue,
729            }
730            self.update_display(options)?;
731            self.draw(wrong_key)?;
732        }
733    }
734
735    fn page_down(&mut self) {
736        // Move the viewing window down by the number of lines to display
737        self.upper_mark = self.upper_mark.saturating_add(self.content_rows);
738    }
739
740    fn next_line(&mut self) {
741        // Move the viewing window down by one line
742        self.upper_mark = self.upper_mark.saturating_add(1);
743    }
744
745    fn page_up(&mut self) {
746        self.eof_reached = false;
747        // Move the viewing window up by the number of lines to display
748        self.upper_mark = self
749            .upper_mark
750            .saturating_sub(self.content_rows.saturating_add(self.lines_squeezed));
751        if self.squeeze {
752            // Move upper mark to the first non-empty line
753            while self.upper_mark > 0 {
754                let line = self.lines.get(self.upper_mark).expect("line should exist");
755                if !line.trim().is_empty() {
756                    break;
757                }
758                self.upper_mark = self.upper_mark.saturating_sub(1);
759            }
760        }
761    }
762
763    fn prev_line(&mut self) {
764        self.eof_reached = false;
765        // Move the viewing window up by one line
766        self.upper_mark = self.upper_mark.saturating_sub(1);
767    }
768
769    // TODO: Deal with column size changes.
770    fn page_resize(&mut self, _col: u16, row: u16, option_line: Option<u16>) {
771        if option_line.is_none() {
772            self.content_rows = row.saturating_sub(1) as usize;
773        }
774    }
775
776    fn draw(&mut self, wrong_key: Option<char>) -> UResult<()> {
777        self.draw_lines()?;
778        self.draw_status_bar(wrong_key);
779        self.stdout.flush()?;
780        Ok(())
781    }
782
783    fn draw_lines(&mut self) -> UResult<()> {
784        // Clear current prompt line
785        self.stdout.queue(Clear(ClearType::CurrentLine))?;
786        // Reset squeezed lines counter
787        self.lines_squeezed = 0;
788        // Display lines until we've filled the screen
789        let mut lines_printed = 0;
790        let mut index = self.upper_mark;
791        while lines_printed < self.content_rows {
792            // Load the required line or stop at EOF
793            if !self.read_until_line(index)? {
794                self.eof_reached = true;
795                self.upper_mark = index.saturating_sub(self.content_rows);
796                break;
797            }
798            // Skip line if it should be squeezed
799            if self.should_squeeze_line(index) {
800                self.lines_squeezed += 1;
801                index += 1;
802                continue;
803            }
804            // Display the line
805            let mut line = self.lines[index].clone();
806            if let Some(pattern) = &self.pattern {
807                // Highlight the pattern in the line
808                line = line.replace(
809                    pattern,
810                    &format!("{}{pattern}{}", Attribute::Reverse, Attribute::Reset),
811                );
812            }
813            self.stdout.write_all(format!("\r{line}\n").as_bytes())?;
814            lines_printed += 1;
815            index += 1;
816        }
817        // Fill remaining lines with `~`
818        while lines_printed < self.content_rows {
819            self.stdout.write_all(b"\r~\n")?;
820            lines_printed += 1;
821        }
822        Ok(())
823    }
824
825    fn should_squeeze_line(&self, index: usize) -> bool {
826        // Only squeeze if enabled and not the first line
827        if !self.squeeze || index == 0 {
828            return false;
829        }
830        // Squeeze only if both current and previous lines are empty
831        match (self.lines.get(index), self.lines.get(index - 1)) {
832            (Some(current), Some(previous)) => current.is_empty() && previous.is_empty(),
833            _ => false,
834        }
835    }
836
837    fn draw_status_bar(&mut self, wrong_key: Option<char>) {
838        // Calculate the index of the last visible line
839        let lower_mark =
840            (self.upper_mark + self.content_rows).min(self.lines.len().saturating_sub(1));
841        // Determine progress information to display
842        // - Show next file name when at EOF and there is a next file
843        // - Otherwise show percentage of the file read (if available)
844        let progress_info = if self.eof_reached && self.next_file.is_some() {
845            format!(" (Next file: {})", self.next_file.unwrap())
846        } else if let Some(file_size) = self.file_size {
847            // For files, show percentage or END
848            let position = self
849                .cumulative_line_sizes
850                .get(lower_mark)
851                .copied()
852                .unwrap_or_default();
853            if file_size == 0 {
854                " (END)".to_string()
855            } else {
856                let percentage = (position as f64 / file_size as f64 * 100.0).round() as u16;
857                if percentage >= 100 {
858                    " (END)".to_string()
859                } else {
860                    format!(" ({percentage}%)")
861                }
862            }
863        } else {
864            // For stdin, don't show percentage
865            String::new()
866        };
867        // Base status message with progress info
868        let file_name = self.file_name.unwrap_or(":");
869        let status = format!("{file_name}{progress_info}");
870        // Add appropriate user feedback based on silent mode and key input:
871        // - In silent mode: show help text or unknown key message
872        // - In normal mode: ring bell (BELL char) on wrong key or show basic prompt
873        let banner = match (self.silent, wrong_key) {
874            (true, Some(key)) => format!(
875                "{status}[{}]",
876                translate!(
877                    "more-error-unknown-key",
878                    "key" => key,
879                )
880            ),
881            (true, None) => format!("{status}{}", translate!("more-help-message")),
882            (false, Some(_)) => format!("{status}{BELL}"),
883            (false, None) => status,
884        };
885        // Draw the status bar at the bottom of the screen
886        write!(
887            self.stdout,
888            "\r{}{banner}{}",
889            Attribute::Reverse,
890            Attribute::Reset
891        )
892        .unwrap();
893    }
894}
895
896#[cfg(test)]
897mod tests {
898    use std::{
899        io::Seek,
900        ops::{Deref, DerefMut},
901    };
902
903    use super::*;
904    use tempfile::tempfile;
905
906    impl Deref for OutputType {
907        type Target = Vec<u8>;
908        fn deref(&self) -> &Vec<u8> {
909            match self {
910                Self::Test(buf) => buf,
911                _ => unreachable!(),
912            }
913        }
914    }
915
916    impl DerefMut for OutputType {
917        fn deref_mut(&mut self) -> &mut Vec<u8> {
918            match self {
919                Self::Test(buf) => buf,
920                _ => unreachable!(),
921            }
922        }
923    }
924
925    struct TestPagerBuilder {
926        content: String,
927        options: Options,
928        rows: u16,
929        next_file: Option<&'static str>,
930    }
931
932    impl Default for TestPagerBuilder {
933        fn default() -> Self {
934            Self {
935                content: String::new(),
936                options: Options {
937                    silent: false,
938                    _logical: false,
939                    _exit_on_eof: false,
940                    _no_pause: false,
941                    print_over: false,
942                    clean_print: false,
943                    squeeze: false,
944                    lines: None,
945                    from_line: 0,
946                    pattern: None,
947                },
948                rows: 10,
949                next_file: None,
950            }
951        }
952    }
953
954    #[allow(dead_code)]
955    impl TestPagerBuilder {
956        fn new(content: &str) -> Self {
957            Self {
958                content: content.to_string(),
959                ..Default::default()
960            }
961        }
962
963        fn build(mut self) -> Pager<'static> {
964            let mut tmpfile = tempfile().unwrap();
965            tmpfile.write_all(self.content.as_bytes()).unwrap();
966            tmpfile.rewind().unwrap();
967            let out = OutputType::Test(Vec::new());
968            if let Some(rows) = self.options.lines {
969                self.rows = rows;
970            }
971            Pager::new(
972                InputType::File(BufReader::new(tmpfile)),
973                self.rows,
974                None,
975                self.next_file,
976                &self.options,
977                out,
978            )
979            .unwrap()
980        }
981
982        fn silent(mut self) -> Self {
983            self.options.silent = true;
984            self
985        }
986
987        fn print_over(mut self) -> Self {
988            self.options.print_over = true;
989            self
990        }
991
992        fn clean_print(mut self) -> Self {
993            self.options.clean_print = true;
994            self
995        }
996
997        fn squeeze(mut self) -> Self {
998            self.options.squeeze = true;
999            self
1000        }
1001
1002        fn lines(mut self, lines: u16) -> Self {
1003            self.options.lines = Some(lines);
1004            self
1005        }
1006
1007        #[allow(clippy::wrong_self_convention)]
1008        fn from_line(mut self, from_line: usize) -> Self {
1009            self.options.from_line = from_line;
1010            self
1011        }
1012
1013        fn pattern(mut self, pattern: &str) -> Self {
1014            self.options.pattern = Some(pattern.to_owned());
1015            self
1016        }
1017
1018        fn rows(mut self, rows: u16) -> Self {
1019            self.rows = rows;
1020            self
1021        }
1022
1023        fn next_file(mut self, next_file: &'static str) -> Self {
1024            self.next_file = Some(next_file);
1025            self
1026        }
1027    }
1028
1029    #[test]
1030    fn test_get_line_and_len() {
1031        let content = "a\n\tb\nc\n";
1032        let mut pager = TestPagerBuilder::new(content).build();
1033        assert_eq!(pager.get_line(1).unwrap(), "\tb");
1034        assert_eq!(pager.cumulative_line_sizes.len(), 2);
1035        assert_eq!(pager.cumulative_line_sizes[1], 5);
1036    }
1037
1038    #[test]
1039    fn test_navigate_page() {
1040        // create 10 lines "0\n".."9\n"
1041        let content = (0..10).map(|i| i.to_string() + "\n").collect::<String>();
1042
1043        // content_rows = rows - 1 = 10 - 1 = 9
1044        let mut pager = TestPagerBuilder::new(&content).build();
1045        assert_eq!(pager.upper_mark, 0);
1046
1047        pager.page_down();
1048        assert_eq!(pager.upper_mark, pager.content_rows);
1049        pager.draw(None).unwrap();
1050        let mut stdout = String::from_utf8_lossy(&pager.stdout);
1051        assert!(stdout.contains("9\n"));
1052        assert!(!stdout.contains("8\n"));
1053        assert_eq!(pager.upper_mark, 1); // EOF reached: upper_mark = 10 - content_rows = 1
1054
1055        pager.page_up();
1056        assert_eq!(pager.upper_mark, 0);
1057
1058        pager.next_line();
1059        assert_eq!(pager.upper_mark, 1);
1060
1061        pager.prev_line();
1062        assert_eq!(pager.upper_mark, 0);
1063        pager.stdout.clear();
1064        pager.draw(None).unwrap();
1065        stdout = String::from_utf8_lossy(&pager.stdout);
1066        assert!(stdout.contains("0\n"));
1067        assert!(!stdout.contains("9\n")); // only lines 0 to 8 should be displayed
1068    }
1069
1070    #[test]
1071    fn test_silent_mode() {
1072        let content = (0..5).map(|i| i.to_string() + "\n").collect::<String>();
1073        let mut pager = TestPagerBuilder::new(&content)
1074            .from_line(3)
1075            .silent()
1076            .build();
1077        pager.draw_status_bar(None);
1078        let stdout = String::from_utf8_lossy(&pager.stdout);
1079        assert!(stdout.contains(&translate!("more-help-message")));
1080    }
1081
1082    #[test]
1083    fn test_squeeze() {
1084        let content = "Line 0\n\n\n\nLine 4\n\n\nLine 7\n";
1085        let mut pager = TestPagerBuilder::new(content).lines(6).squeeze().build();
1086        assert_eq!(pager.content_rows, 5); // 1 line for the status bar
1087
1088        // load all lines
1089        assert!(pager.read_until_line(7).unwrap());
1090        //  back‑to‑back empty lines → should squeeze
1091        assert!(pager.should_squeeze_line(2));
1092        assert!(pager.should_squeeze_line(3));
1093        assert!(pager.should_squeeze_line(6));
1094        // non‑blank or first line should not be squeezed
1095        assert!(!pager.should_squeeze_line(0));
1096        assert!(!pager.should_squeeze_line(1));
1097        assert!(!pager.should_squeeze_line(4));
1098        assert!(!pager.should_squeeze_line(5));
1099        assert!(!pager.should_squeeze_line(7));
1100
1101        pager.draw(None).unwrap();
1102        let stdout = String::from_utf8_lossy(&pager.stdout);
1103        assert!(stdout.contains("Line 0"));
1104        assert!(stdout.contains("Line 4"));
1105        assert!(stdout.contains("Line 7"));
1106    }
1107
1108    #[test]
1109    fn test_lines_option() {
1110        let content = (0..5).map(|i| i.to_string() + "\n").collect::<String>();
1111
1112        // Output zero lines succeeds
1113        let mut pager = TestPagerBuilder::new(&content).lines(0).build();
1114        pager.draw(None).unwrap();
1115        let mut stdout = String::from_utf8_lossy(&pager.stdout);
1116        assert!(!stdout.is_empty());
1117
1118        // Output two lines
1119        let mut pager = TestPagerBuilder::new(&content).lines(3).build();
1120        assert_eq!(pager.content_rows, 3 - 1); // 1 line for the status bar
1121        pager.draw(None).unwrap();
1122        stdout = String::from_utf8_lossy(&pager.stdout);
1123        assert!(stdout.contains("0\n"));
1124        assert!(stdout.contains("1\n"));
1125        assert!(!stdout.contains("2\n"));
1126    }
1127
1128    #[test]
1129    fn test_from_line_option() {
1130        let content = (0..5).map(|i| i.to_string() + "\n").collect::<String>();
1131
1132        // Output from first line
1133        let mut pager = TestPagerBuilder::new(&content).from_line(0).build();
1134        assert!(pager.handle_from_line().is_ok());
1135        pager.draw(None).unwrap();
1136        let stdout = String::from_utf8_lossy(&pager.stdout);
1137        assert!(stdout.contains("0\n"));
1138
1139        // Output from second line
1140        pager = TestPagerBuilder::new(&content).from_line(1).build();
1141        assert!(pager.handle_from_line().is_ok());
1142        pager.draw(None).unwrap();
1143        let stdout = String::from_utf8_lossy(&pager.stdout);
1144        assert!(stdout.contains("1\n"));
1145        assert!(!stdout.contains("0\n"));
1146
1147        // Output from out of range line
1148        pager = TestPagerBuilder::new(&content).from_line(99).build();
1149        assert!(pager.handle_from_line().is_ok());
1150        assert_eq!(pager.upper_mark, 0);
1151        let stdout = String::from_utf8_lossy(&pager.stdout);
1152        assert!(stdout.contains(&translate!(
1153            "more-error-cannot-seek-to-line",
1154            "line" => "100"
1155        )));
1156    }
1157
1158    #[test]
1159    fn test_search_pattern_found() {
1160        let content = "foo\nbar\nbaz\n";
1161        let mut pager = TestPagerBuilder::new(content).pattern("bar").build();
1162        assert!(pager.handle_pattern_search().is_ok());
1163        assert_eq!(pager.upper_mark, 1);
1164        pager.draw(None).unwrap();
1165        let stdout = String::from_utf8_lossy(&pager.stdout);
1166        assert!(stdout.contains("bar"));
1167        assert!(!stdout.contains("foo"));
1168    }
1169
1170    #[test]
1171    fn test_search_pattern_not_found() {
1172        let content = "foo\nbar\nbaz\n";
1173        let mut pager = TestPagerBuilder::new(content).pattern("qux").build();
1174        assert!(pager.handle_pattern_search().is_ok());
1175        let stdout = String::from_utf8_lossy(&pager.stdout);
1176        assert!(stdout.contains(&translate!("more-error-pattern-not-found")));
1177        assert_eq!(pager.pattern, None);
1178        assert_eq!(pager.upper_mark, 0);
1179    }
1180
1181    #[test]
1182    fn test_wrong_key() {
1183        let mut pager = TestPagerBuilder::default().silent().build();
1184        pager.draw_status_bar(Some('x'));
1185        let stdout = String::from_utf8_lossy(&pager.stdout);
1186        assert!(stdout.contains(&translate!(
1187            "more-error-unknown-key",
1188            "key" => "x"
1189        )));
1190
1191        pager = TestPagerBuilder::default().build();
1192        pager.draw_status_bar(Some('x'));
1193        let stdout = String::from_utf8_lossy(&pager.stdout);
1194        assert!(stdout.contains(&BELL.to_string()));
1195    }
1196}