Skip to main content

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