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