Skip to main content

rusty_rich/
screen.rs

1//! Screen — full-screen renderable and alternate screen buffer.
2//!
3//! Provides the `Screen` renderable that fills the terminal, cropping or
4//! padding content to exactly fit the screen dimensions. Also provides
5//! `ScreenContext` for managing the alternate screen buffer and
6//! `ScreenUpdate` for partial screen updates.
7//!
8//! Equivalent to Rich's `screen.py`.
9
10use std::io::Write;
11
12use crate::console::{ConsoleOptions, DynRenderable, RenderResult, Renderable};
13use crate::segment::Segment;
14use crate::style::Style;
15
16// ---------------------------------------------------------------------------
17// Screen
18// ---------------------------------------------------------------------------
19
20/// A renderable that fills the entire terminal screen, cropping or padding
21/// its content to exactly fit the screen dimensions.
22///
23/// Equivalent to Rich's `Screen` class.
24pub struct Screen {
25    /// The child renderable.
26    pub renderable: DynRenderable,
27    /// Optional style applied as a background / padding style.
28    pub style: Option<Style>,
29    /// If true, use `\n\r` line endings (application mode for raw terminals).
30    pub application_mode: bool,
31}
32
33impl Screen {
34    /// Create a new Screen wrapping the given renderable.
35    pub fn new(renderable: impl Renderable + Send + Sync + 'static) -> Self {
36        Self {
37            renderable: DynRenderable::new(renderable),
38            style: None,
39            application_mode: false,
40        }
41    }
42
43    /// Builder: set the optional background / padding style.
44    pub fn style(mut self, style: Style) -> Self {
45        self.style = Some(style);
46        self
47    }
48
49    /// Builder: set application mode (uses `\n\r` instead of `\n`).
50    pub fn application_mode(mut self, mode: bool) -> Self {
51        self.application_mode = mode;
52        self
53    }
54
55    /// Update the content renderable.
56    pub fn update<T>(&mut self, update: T)
57    where
58        T: Into<ScreenUpdate>,
59    {
60        let update = update.into();
61        self.renderable = update.renderable;
62    }
63}
64
65impl std::fmt::Debug for Screen {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("Screen")
68            .field("style", &self.style)
69            .field("application_mode", &self.application_mode)
70            .finish()
71    }
72}
73
74impl Renderable for Screen {
75    fn render(&self, options: &ConsoleOptions) -> RenderResult {
76        let width = options.size.width.max(1);
77        let height = options.size.height.max(1);
78
79        // Create render options that match the full screen size
80        let render_options = options
81            .update_width(width)
82            .update_height(height);
83
84        // Render the inner content
85        let result = self.renderable.render(&render_options);
86
87        // Collect lines from result (handle both `lines` and `items`)
88        let mut lines: Vec<Vec<Segment>> = if !result.lines.is_empty() {
89            result.lines
90        } else {
91            let segments = result.flatten(&render_options);
92            if segments.is_empty() {
93                vec![vec![]]
94            } else {
95                // Group flattened segments into lines by splitting on newlines
96                let mut grouped: Vec<Vec<Segment>> = Vec::new();
97                let mut current_line: Vec<Segment> = Vec::new();
98                for seg in segments {
99                    if seg.text == "\n" || seg.text == "\r\n" {
100                        grouped.push(std::mem::take(&mut current_line));
101                    } else {
102                        current_line.push(seg);
103                    }
104                }
105                if !current_line.is_empty() {
106                    grouped.push(current_line);
107                }
108                if grouped.is_empty() {
109                    grouped.push(vec![]);
110                }
111                grouped
112            }
113        };
114
115        // -- Apply style and shape output to exact screen dimensions --
116
117        // Style all content segments first
118        if let Some(ref screen_style) = self.style {
119            for line in &mut lines {
120                for seg in line.iter_mut() {
121                    if let Some(ref existing) = seg.style {
122                        seg.style = Some(existing.combine(screen_style));
123                    } else {
124                        seg.style = Some(screen_style.clone());
125                    }
126                }
127            }
128        }
129
130        // Crop or pad each line to exact width
131        let blank_seg = if let Some(ref style) = self.style {
132            Segment::styled(" ".repeat(width), style.clone())
133        } else {
134            Segment::new(" ".repeat(width))
135        };
136
137        for line in &mut lines {
138            let line_len: usize = line.iter().map(|s| s.cell_length()).sum();
139            if line_len > width {
140                // Crop the line
141                let mut cropped: Vec<Segment> = Vec::new();
142                let mut accumulated = 0usize;
143                for seg in line.drain(..) {
144                    let seg_len = seg.cell_length();
145                    if accumulated + seg_len <= width {
146                        cropped.push(seg);
147                        accumulated += seg_len;
148                    } else if accumulated < width {
149                        let remaining = width - accumulated;
150                        let (left, _) = seg.split(remaining);
151                        if left.cell_length() > 0 {
152                            cropped.push(left);
153                        }
154                        break;
155                    } else {
156                        break;
157                    }
158                }
159                *line = cropped;
160            } else if line_len < width {
161                // Pad to width with spaces (styled if needed)
162                if let Some(ref style) = self.style {
163                    line.push(Segment::styled(" ".repeat(width - line_len), style.clone()));
164                } else {
165                    line.push(Segment::new(" ".repeat(width - line_len)));
166                }
167            }
168        }
169
170        // Crop or pad height
171        if lines.len() > height {
172            lines.truncate(height);
173        } else {
174            while lines.len() < height {
175                lines.push(vec![blank_seg.clone()]);
176            }
177        }
178
179        // Insert newline segments between lines (not after the last)
180        let new_line_char = if self.application_mode { "\n\r" } else { "\n" };
181        let mut final_lines: Vec<Vec<Segment>> = Vec::with_capacity(lines.len() * 2);
182        let last_idx = lines.len().saturating_sub(1);
183        for (i, line) in lines.into_iter().enumerate() {
184            final_lines.push(line);
185            if i < last_idx {
186                final_lines.push(vec![Segment::new(new_line_char)]);
187            }
188        }
189
190        RenderResult {
191            lines: final_lines,
192            items: Vec::new(),
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// ScreenUpdate
199// ---------------------------------------------------------------------------
200
201/// Represents an update to a screen display.
202///
203/// Used by [`ScreenContext::update()`] (and [`Screen::update()`]) to replace
204/// the displayed content without creating a new Screen.
205pub struct ScreenUpdate {
206    /// The new renderable to display.
207    pub renderable: DynRenderable,
208}
209
210impl ScreenUpdate {
211    /// Create a new ScreenUpdate wrapping the given renderable.
212    pub fn new(renderable: impl Renderable + Send + Sync + 'static) -> Self {
213        Self {
214            renderable: DynRenderable::new(renderable),
215        }
216    }
217}
218
219impl std::fmt::Debug for ScreenUpdate {
220    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
221        f.debug_struct("ScreenUpdate").finish()
222    }
223}
224
225impl<R> From<R> for ScreenUpdate
226where
227    R: Renderable + Send + Sync + 'static,
228{
229    fn from(renderable: R) -> Self {
230        Self::new(renderable)
231    }
232}
233
234// ---------------------------------------------------------------------------
235// ScreenContext
236// ---------------------------------------------------------------------------
237
238/// A context that enters the alternate screen buffer, provides an [`update`](Self::update)
239/// method to display content, and automatically exits the alternate screen
240/// buffer on drop.
241///
242/// Created via [`Console::screen()`](crate::console::Console::screen).
243///
244/// # Example
245///
246/// ```ignore
247/// let mut console = Console::new();
248/// let ctx = console.screen();
249/// ctx.update("Hello from alt-screen!");
250/// std::thread::sleep(std::time::Duration::from_secs(2));
251/// // ctx drops → exits alt screen
252/// ```
253pub struct ScreenContext {
254    /// Whether the alternate screen is currently active.
255    active: bool,
256    /// Optional style applied to screen content.
257    style: Option<Style>,
258}
259
260impl ScreenContext {
261    /// Create a new ScreenContext (does **not** enter alt screen yet).
262    pub fn new() -> Self {
263        Self {
264            active: false,
265            style: None,
266        }
267    }
268
269    /// Builder: set the style for screen content.
270    pub fn style(mut self, style: Style) -> Self {
271        self.style = Some(style);
272        self
273    }
274
275    /// Enter the alternate screen buffer.
276    pub fn enter(&mut self) {
277        if !self.active {
278            let _ = write!(std::io::stdout(), "\x1b[?1049h");
279            let _ = std::io::stdout().flush();
280            self.active = true;
281        }
282    }
283
284    /// Exit the alternate screen buffer, restoring the original screen.
285    pub fn exit(&mut self) {
286        if self.active {
287            let _ = write!(std::io::stdout(), "\x1b[?1049l");
288            let _ = std::io::stdout().flush();
289            self.active = false;
290        }
291    }
292
293    /// Render the given content in the alternate screen.
294    pub fn update(&mut self, update: impl Into<ScreenUpdate>) -> std::io::Result<()> {
295        if !self.active {
296            self.enter();
297        }
298
299        let opts = ConsoleOptions::default();
300        let screen = Screen {
301            renderable: update.into().renderable,
302            style: self.style.clone(),
303            application_mode: false,
304        };
305        let result = screen.render(&opts);
306        let ansi = result.to_ansi();
307        write!(std::io::stdout(), "{ansi}")?;
308        std::io::stdout().flush()
309    }
310
311    /// Check whether the alternate screen is currently active.
312    pub fn is_active(&self) -> bool {
313        self.active
314    }
315}
316
317impl Default for ScreenContext {
318    fn default() -> Self {
319        Self::new()
320    }
321}
322
323impl Drop for ScreenContext {
324    fn drop(&mut self) {
325        self.exit();
326    }
327}
328
329impl std::fmt::Debug for ScreenContext {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        f.debug_struct("ScreenContext")
332            .field("active", &self.active)
333            .finish()
334    }
335}
336
337// ---------------------------------------------------------------------------
338// Tests
339// ---------------------------------------------------------------------------
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::console::ConsoleDimensions;
345    use crate::style::Style;
346
347    #[test]
348    fn test_screen_creation() {
349        let screen = Screen::new("Hello");
350        assert!(screen.style.is_none());
351        assert!(!screen.application_mode);
352    }
353
354    #[test]
355    fn test_screen_with_style() {
356        let screen = Screen::new("Hello").style(Style::new().bold(true));
357        assert!(screen.style.is_some());
358    }
359
360    #[test]
361    fn test_screen_application_mode() {
362        let screen = Screen::new("Hello").application_mode(true);
363        assert!(screen.application_mode);
364    }
365
366    #[test]
367    fn test_screen_crops_wide_content() {
368        let screen = Screen::new("Hello World!!!");
369        let opts = ConsoleOptions {
370            size: ConsoleDimensions {
371                width: 5,
372                height: 1,
373            },
374            max_width: 5,
375            max_height: 1,
376            ..Default::default()
377        };
378        let result = screen.render(&opts);
379        let ansi = result.to_ansi();
380        // Should be cropped to 5 chars
381        assert!(ansi.contains("Hello"));
382        assert!(!ansi.contains("World"));
383    }
384
385    #[test]
386    fn test_screen_pads_to_height() {
387        let screen = Screen::new("Hi");
388        let opts = ConsoleOptions {
389            size: ConsoleDimensions {
390                width: 10,
391                height: 5,
392            },
393            max_width: 10,
394            max_height: 5,
395            ..Default::default()
396        };
397        let result = screen.render(&opts);
398        let ansi = result.to_ansi();
399        // Should have content and padding (look for the text and then spaces)
400        assert!(ansi.contains("Hi"));
401    }
402
403    #[test]
404    fn test_screen_returns_render_result() {
405        let screen = Screen::new("Test content");
406        let opts = ConsoleOptions {
407            size: ConsoleDimensions {
408                width: 80,
409                height: 24,
410            },
411            max_width: 80,
412            max_height: 24,
413            ..Default::default()
414        };
415        let result = screen.render(&opts);
416        assert!(!result.lines.is_empty());
417    }
418
419    #[test]
420    fn test_screen_update_creation() {
421        let update = ScreenUpdate::new("Updated content");
422        let mut screen = Screen::new("Original");
423        screen.update(update);
424        let opts = ConsoleOptions {
425            size: ConsoleDimensions {
426                width: 80,
427                height: 24,
428            },
429            max_width: 80,
430            max_height: 24,
431            ..Default::default()
432        };
433        let result = screen.render(&opts);
434        let ansi = result.to_ansi();
435        assert!(ansi.contains("Updated"));
436    }
437
438    #[test]
439    fn test_screen_update_from_renderable() {
440        // Test the From impl
441        let update: ScreenUpdate = "Direct string".into();
442        let _screen = Screen::new(update.renderable);
443    }
444
445    #[test]
446    fn test_screen_context_creation() {
447        let ctx = ScreenContext::new();
448        assert!(!ctx.is_active());
449    }
450
451    #[test]
452    fn test_screen_context_default() {
453        let ctx = ScreenContext::default();
454        assert!(!ctx.is_active());
455    }
456
457    #[test]
458    fn test_screen_context_enter_exit() {
459        let mut ctx = ScreenContext::new();
460        // enter (don't assert on terminal escape but verify state changes)
461        ctx.enter();
462        assert!(ctx.is_active());
463        ctx.exit();
464        assert!(!ctx.is_active());
465    }
466
467    #[test]
468    fn test_screen_context_double_enter() {
469        let mut ctx = ScreenContext::new();
470        ctx.enter();
471        assert!(ctx.is_active());
472        // Second enter should be safe (no-op)
473        ctx.enter();
474        assert!(ctx.is_active());
475    }
476}