oxur_cli/repl/
pager.rs

1//! Pager support for displaying long content
2//!
3//! Provides automatic paging for help and other long text output.
4//! Auto-detects terminal height and only pages if content doesn't fit.
5
6use crossterm::{
7    event::{self, Event, KeyCode, KeyEvent},
8    terminal::{disable_raw_mode, enable_raw_mode},
9};
10use std::io::{self, Write};
11
12/// Default terminal height if detection fails
13const DEFAULT_TERM_HEIGHT: usize = 24;
14
15/// Page through text content if it exceeds terminal height
16///
17/// Automatically detects terminal height and pages content if needed.
18/// Uses a simple "Press Space to continue, q to quit" interface.
19///
20/// # Arguments
21///
22/// * `content` - The text to display (may contain newlines)
23///
24/// # Returns
25///
26/// Returns `Ok(())` if successful, or an IO error.
27///
28/// # Example
29///
30/// ```no_run
31/// use oxur_cli::repl::pager;
32///
33/// let long_help = "Line 1\nLine 2\n...";
34/// pager::page_text(long_help).expect("Failed to page");
35/// ```
36pub fn page_text(content: &str) -> io::Result<()> {
37    let lines: Vec<&str> = content.lines().collect();
38    let line_count = lines.len();
39
40    // Get terminal height
41    let term_height = get_terminal_height();
42
43    // If content fits on screen, just print it
44    if line_count <= term_height - 2 {
45        println!("{}", content);
46        return Ok(());
47    }
48
49    // Content is too long - page it
50    page_lines(&lines, term_height)
51}
52
53/// Get the terminal height
54fn get_terminal_height() -> usize {
55    terminal_size::terminal_size()
56        .map(|(_, terminal_size::Height(h))| h as usize)
57        .unwrap_or(DEFAULT_TERM_HEIGHT)
58}
59
60/// Page through lines with prompts
61fn page_lines(lines: &[&str], term_height: usize) -> io::Result<()> {
62    let page_size = term_height - 2; // Reserve 2 lines for prompt
63    let mut current_line = 0;
64    let total_lines = lines.len();
65
66    let mut stdout = io::stdout();
67
68    // Enable raw mode for single-key reading
69    enable_raw_mode().map_err(io::Error::other)?;
70
71    let result = (|| -> io::Result<()> {
72        while current_line < total_lines {
73            // Calculate how many lines to show
74            let end_line = (current_line + page_size).min(total_lines);
75
76            // Print the page (use \r\n for raw mode)
77            for line in &lines[current_line..end_line] {
78                write!(stdout, "{}\r\n", line)?;
79            }
80            stdout.flush()?;
81
82            current_line = end_line;
83
84            // If we've shown everything, we're done
85            if current_line >= total_lines {
86                break;
87            }
88
89            // Show prompt
90            let remaining = total_lines - current_line;
91            write!(
92                stdout,
93                "\x1b[7m-- More ({} lines remaining) -- Press Space to continue, q to quit \x1b[0m",
94                remaining
95            )?;
96            stdout.flush()?;
97
98            // Read single key press
99            loop {
100                if let Event::Key(KeyEvent { code, .. }) =
101                    event::read().map_err(io::Error::other)?
102                {
103                    match code {
104                        KeyCode::Char(' ') => {
105                            // Continue to next page
106                            break;
107                        }
108                        KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
109                            // Clear prompt and exit
110                            write!(stdout, "\r\x1b[K")?;
111                            return Ok(());
112                        }
113                        _ => {
114                            // Ignore other keys
115                            continue;
116                        }
117                    }
118                }
119            }
120
121            // Clear the prompt line
122            write!(stdout, "\r\x1b[K")?;
123        }
124
125        Ok(())
126    })();
127
128    // Always restore terminal mode
129    disable_raw_mode().map_err(io::Error::other)?;
130
131    result
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_get_terminal_height() {
140        let height = get_terminal_height();
141        // Should be either detected or default
142        assert!(height > 0);
143        assert!(height <= 200); // Sanity check
144    }
145
146    #[test]
147    fn test_short_content_no_paging() {
148        // Short content should work without interaction
149        let content = "Line 1\nLine 2\nLine 3";
150        // Can't easily test the actual paging without a TTY,
151        // but we can verify the function doesn't panic
152        let result = page_text(content);
153        // This will print to stdout, which is fine for tests
154        assert!(result.is_ok() || result.is_err());
155    }
156
157    #[test]
158    fn test_empty_content() {
159        let result = page_text("");
160        assert!(result.is_ok());
161    }
162
163    #[test]
164    fn test_single_line() {
165        let result = page_text("Single line");
166        assert!(result.is_ok());
167    }
168
169    #[test]
170    fn test_content_with_newlines() {
171        let content = "Line 1\nLine 2\nLine 3\nLine 4\n";
172        let result = page_text(content);
173        assert!(result.is_ok());
174    }
175}