Skip to main content

rich_rs/
screen.rs

1//! Screen: A renderable that fills the terminal screen.
2//!
3//! Port of Python Rich's `rich/screen.py`.
4
5use std::sync::Arc;
6
7use crate::Renderable;
8use crate::console::Console;
9use crate::console::ConsoleOptions;
10use crate::group::Group;
11use crate::loop_helpers::loop_last;
12use crate::segment::{Segment, Segments};
13use crate::style::Style;
14
15/// A renderable that fills the terminal screen and crops excess.
16///
17/// Screen renders its content to fill the full terminal dimensions,
18/// cropping any excess and padding if needed.
19///
20/// # Example
21///
22/// ```
23/// use rich_rs::{Console, Text};
24/// use rich_rs::screen::Screen;
25///
26/// let text = Text::plain("Hello, World!");
27/// let screen = Screen::new(text);
28/// ```
29pub struct Screen {
30    /// The content to render.
31    renderable: Arc<dyn Renderable>,
32    /// Optional background style.
33    style: Option<Style>,
34    /// If true, use application mode newlines (\n\r instead of \n).
35    application_mode: bool,
36}
37
38impl Screen {
39    /// Create a new Screen with a renderable.
40    pub fn new(renderable: impl Renderable + 'static) -> Self {
41        Self {
42            renderable: Arc::new(renderable),
43            style: None,
44            application_mode: false,
45        }
46    }
47
48    /// Create a new Screen from multiple renderables.
49    ///
50    /// Python Rich's `Screen(*renderables)` wraps the inputs in `Group`.
51    /// This is the closest Rust equivalent.
52    pub fn new_many<I, R>(renderables: I) -> Self
53    where
54        I: IntoIterator<Item = R>,
55        R: Renderable + 'static,
56    {
57        Self::new(Group::new(renderables))
58    }
59
60    /// Create a new Screen from an `Arc<dyn Renderable>`.
61    ///
62    /// This avoids an extra Arc allocation when you already have one.
63    pub fn from_arc(renderable: Arc<dyn Renderable>) -> Self {
64        Self {
65            renderable,
66            style: None,
67            application_mode: false,
68        }
69    }
70
71    /// Set the background style.
72    pub fn with_style(mut self, style: impl Into<Style>) -> Self {
73        self.style = Some(style.into());
74        self
75    }
76
77    /// Enable application mode (uses \n\r newlines).
78    ///
79    /// Application mode is typically used when the terminal is in alternate
80    /// screen mode, where `\n\r` provides correct cursor positioning.
81    pub fn with_application_mode(mut self, mode: bool) -> Self {
82        self.application_mode = mode;
83        self
84    }
85}
86
87impl Renderable for Screen {
88    fn render(&self, console: &Console, options: &ConsoleOptions) -> Segments {
89        let (width, height) = options.size;
90
91        // Create options with full screen dimensions
92        let mut render_options = options.clone();
93        render_options.size = (width, height);
94        render_options.min_width = width.max(1);
95        render_options.max_width = width.max(1);
96        render_options.max_height = height;
97        render_options.height = Some(height);
98
99        // Render to lines with padding
100        let lines = console.render_lines(
101            &*self.renderable,
102            Some(&render_options),
103            self.style,
104            true,  // pad
105            false, // new_lines - we'll add them manually
106        );
107
108        // Set shape to exact screen size
109        let lines = Segment::set_shape(&lines, width, Some(height), self.style, false);
110
111        // Build output with appropriate newlines
112        let new_line = if self.application_mode {
113            Segment::new("\n\r")
114        } else {
115            Segment::line()
116        };
117
118        let mut out = Segments::new();
119        for (is_last, line) in loop_last(lines.into_iter()) {
120            for seg in line {
121                out.push(seg);
122            }
123            if !is_last {
124                out.push(new_line.clone());
125            }
126        }
127        out
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::Text;
135
136    #[test]
137    fn test_screen_new_many() {
138        let console = Console::new();
139        let options = ConsoleOptions {
140            size: (10, 3),
141            max_width: 10,
142            max_height: 3,
143            ..Default::default()
144        };
145
146        let screen = Screen::new_many([Text::plain("A"), Text::plain("B")]);
147        let output: String = screen
148            .render(&console, &options)
149            .iter()
150            .map(|s| s.text.to_string())
151            .collect();
152
153        // Should contain both lines in order, separated by newline.
154        assert!(output.contains("A"));
155        assert!(output.contains("B"));
156        assert!(output.contains('\n'));
157    }
158
159    #[test]
160    fn test_screen_basic() {
161        let console = Console::new();
162        let options = ConsoleOptions {
163            size: (10, 3),
164            max_width: 10,
165            max_height: 3,
166            ..Default::default()
167        };
168
169        let text = Text::plain("Hello");
170        let screen = Screen::new(text);
171        let segments = screen.render(&console, &options);
172
173        // Should produce 3 lines of 10 characters each
174        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
175
176        // Count newlines - should have 2 (between 3 lines)
177        let newline_count = output.matches('\n').count();
178        assert_eq!(newline_count, 2);
179
180        // Check that lines are padded to width
181        let lines: Vec<&str> = output.split('\n').collect();
182        assert_eq!(lines.len(), 3);
183        for line in &lines {
184            // Each line should be 10 cells wide
185            assert_eq!(crate::cell_len(line), 10);
186        }
187    }
188
189    #[test]
190    fn test_screen_with_style() {
191        use crate::SimpleColor;
192
193        let console = Console::new();
194        let options = ConsoleOptions {
195            size: (10, 2),
196            max_width: 10,
197            max_height: 2,
198            ..Default::default()
199        };
200
201        // Use Standard ANSI red (color 1)
202        let style = Style::new().with_bgcolor(SimpleColor::Standard(1));
203        let text = Text::plain("Hi");
204        let screen = Screen::new(text).with_style(style);
205        let segments = screen.render(&console, &options);
206
207        // Check that style is applied to padding segments
208        let styled_segments: Vec<_> = segments
209            .iter()
210            .filter(|s| s.style.is_some() && !s.text.is_empty() && s.text.as_ref() != "\n")
211            .collect();
212
213        assert!(!styled_segments.is_empty());
214    }
215
216    #[test]
217    fn test_screen_application_mode() {
218        let console = Console::new();
219        let options = ConsoleOptions {
220            size: (5, 2),
221            max_width: 5,
222            max_height: 2,
223            ..Default::default()
224        };
225
226        let text = Text::plain("a\nb");
227        let screen = Screen::new(text).with_application_mode(true);
228        let segments = screen.render(&console, &options);
229
230        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
231
232        // Should use \n\r instead of \n
233        assert!(output.contains("\n\r"));
234        // Should not have plain \n (except as part of \n\r)
235        let plain_newlines = output
236            .chars()
237            .zip(output.chars().skip(1).chain(std::iter::once('\0')))
238            .filter(|&(c, next)| c == '\n' && next != '\r')
239            .count();
240        assert_eq!(plain_newlines, 0);
241    }
242
243    #[test]
244    fn test_screen_crops_excess() {
245        let console = Console::new();
246        let options = ConsoleOptions {
247            size: (5, 2),
248            max_width: 5,
249            max_height: 2,
250            ..Default::default()
251        };
252
253        // Content that exceeds screen size
254        let text = Text::plain("Line 1\nLine 2\nLine 3\nLine 4");
255        let screen = Screen::new(text);
256        let segments = screen.render(&console, &options);
257
258        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
259
260        // Should only have 2 lines (cropped to height)
261        let lines: Vec<&str> = output.split('\n').collect();
262        assert_eq!(lines.len(), 2);
263
264        // Each line should be 5 cells (cropped to width)
265        for line in &lines {
266            assert_eq!(crate::cell_len(line), 5);
267        }
268    }
269
270    #[test]
271    fn test_screen_from_arc() {
272        let console = Console::new();
273        let options = ConsoleOptions {
274            size: (10, 2),
275            max_width: 10,
276            max_height: 2,
277            ..Default::default()
278        };
279
280        let text: Arc<dyn Renderable> = Arc::new(Text::plain("Hello"));
281        let screen = Screen::from_arc(text);
282        let segments = screen.render(&console, &options);
283
284        let output: String = segments.iter().map(|s| s.text.to_string()).collect();
285        assert!(output.contains("Hello"));
286    }
287}