r3bl_tui/tui/terminal_lib_backends/
offscreen_buffer.rs

1// Copyright (c) 2022-2025 R3BL LLC. Licensed under Apache License, Version 2.0.
2use std::{fmt::{self, Debug},
3          ops::{Deref, DerefMut}};
4
5use diff_chunks::PixelCharDiffChunks;
6use smallvec::smallvec;
7
8use super::{FlushKind, RenderOps};
9use crate::{CachedMemorySize, ColWidth, GetMemSize, InlineVec, List, LockedOutputDevice,
10            MemoizedMemorySize, MemorySize, Pos, Size, TinyInlineString, TuiColor,
11            TuiStyle, col, dim_underline, fg_green, fg_magenta, get_mem_size,
12            inline_string, ok, row, tiny_inline_string};
13
14/// Represents a grid of cells where the row/column index maps to the terminal screen.
15///
16/// This works regardless of the size of each cell. Cells can contain emoji who's display
17/// width is greater than one. This complicates things since a "😃" takes up 2 display
18/// widths.
19///
20/// Let's say one cell has a "😃" in it. The cell's display width is 2. The cell's byte
21/// size is 4. The next cell after it will have to contain nothing or void.
22///
23/// Why? This is because the col & row indices of the grid map to display col & row
24/// indices of the terminal screen. By inserting a [`PixelChar::Void`] pixel char in the
25/// next cell, we signal the rendering logic to skip it since it has already been painted.
26/// And this is different than a [`PixelChar::Spacer`] which has to be painted!
27#[derive(Clone, PartialEq)]
28pub struct OffscreenBuffer {
29    pub buffer: PixelCharLines,
30    pub window_size: Size,
31    pub my_pos: Pos,
32    pub my_fg_color: Option<TuiColor>,
33    pub my_bg_color: Option<TuiColor>,
34    /// Memoized memory size calculation for performance.
35    /// This avoids expensive recalculation in
36    /// [`crate::main_event_loop::EventLoopState::log_telemetry_info()`]
37    /// which is called in a hot loop on every render.
38    memory_size_calc_cache: MemoizedMemorySize,
39}
40
41impl GetMemSize for OffscreenBuffer {
42    /// This is the actual calculation, but should rarely be called directly.
43    /// Use [`Self::get_mem_size_cached()`] for performance-critical code.
44    fn get_mem_size(&self) -> usize {
45        self.buffer.get_mem_size()
46            + std::mem::size_of::<Size>()
47            + std::mem::size_of::<Pos>()
48            + std::mem::size_of::<Option<TuiColor>>()
49            + std::mem::size_of::<Option<TuiColor>>()
50    }
51}
52
53impl CachedMemorySize for OffscreenBuffer {
54    fn memory_size_cache(&self) -> &MemoizedMemorySize { &self.memory_size_calc_cache }
55
56    fn memory_size_cache_mut(&mut self) -> &mut MemoizedMemorySize {
57        &mut self.memory_size_calc_cache
58    }
59}
60
61pub mod diff_chunks {
62    #[allow(clippy::wildcard_imports)]
63    use super::*;
64
65    /// This is a wrapper type so the [`std::fmt::Debug`] can be implemented for it, that
66    /// won't conflict with [List]'s implementation of the trait.
67    #[derive(Clone, Default, PartialEq)]
68    pub struct PixelCharDiffChunks {
69        pub inner: List<DiffChunk>,
70    }
71
72    pub type DiffChunk = (Pos, PixelChar);
73
74    impl Deref for PixelCharDiffChunks {
75        type Target = List<DiffChunk>;
76
77        fn deref(&self) -> &Self::Target { &self.inner }
78    }
79
80    impl From<List<DiffChunk>> for PixelCharDiffChunks {
81        fn from(list: List<DiffChunk>) -> Self { Self { inner: list } }
82    }
83}
84
85mod offscreen_buffer_impl {
86    #[allow(clippy::wildcard_imports)]
87    use super::*;
88
89    impl Debug for PixelCharDiffChunks {
90        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91            for (pos, pixel_char) in self.iter() {
92                writeln!(f, "\t{pos:?}: {pixel_char:?}")?;
93            }
94            ok!()
95        }
96    }
97
98    impl Deref for OffscreenBuffer {
99        type Target = PixelCharLines;
100
101        fn deref(&self) -> &Self::Target { &self.buffer }
102    }
103
104    impl DerefMut for OffscreenBuffer {
105        /// Returns a mutable reference to the buffer.
106        ///
107        /// **Important**: This invalidates and recalculates the `memory_size_calc_cache`
108        /// field to ensure telemetry always shows accurate memory size instead of
109        /// "?".
110        fn deref_mut(&mut self) -> &mut Self::Target {
111            // Invalidate and recalculate cache when buffer is accessed mutably
112            self.invalidate_memory_size_calc_cache();
113            &mut self.buffer
114        }
115    }
116
117    impl Debug for OffscreenBuffer {
118        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119            writeln!(f, "window_size: {:?}, ", self.window_size)?;
120
121            let height = self.window_size.row_height.as_usize();
122            for row_index in 0..height {
123                if let Some(row) = self.buffer.get(row_index) {
124                    // Print row separator if needed (not the first item).
125                    if row_index > 0 {
126                        writeln!(f)?;
127                    }
128
129                    // Print the row index (styled) in "this" line.
130                    writeln!(
131                        f,
132                        "{}",
133                        fg_green(&inline_string!("row_index: {}", row_index))
134                    )?;
135
136                    // Print the row itself in the "next" line.
137                    write!(f, "{row:?}")?;
138                }
139            }
140
141            writeln!(f)
142        }
143    }
144
145    impl OffscreenBuffer {
146        /// Gets the cached memory size value, recalculating if necessary.
147        /// This is used in
148        /// [`crate::main_event_loop::EventLoopState::log_telemetry_info()`] for
149        /// performance-critical telemetry logging. The expensive memory calculation is
150        /// only performed if the cache is invalid or empty.
151        #[must_use]
152        pub fn get_mem_size_cached(&mut self) -> MemorySize {
153            self.get_cached_memory_size()
154        }
155
156        /// Invalidates and immediately recalculates the memory size cache.
157        /// Call this when buffer content changes to ensure the cache is always valid.
158        fn invalidate_memory_size_calc_cache(&mut self) {
159            self.invalidate_memory_size_cache();
160            self.update_memory_size_cache(); // Force immediate recalculation to avoid "?" in telemetry
161        }
162
163        /// Checks for differences between self and other. Returns a list of positions and
164        /// pixel chars if there are differences (from other).
165        #[must_use]
166        pub fn diff(&self, other: &Self) -> Option<PixelCharDiffChunks> {
167            if self.window_size != other.window_size {
168                return None;
169            }
170
171            let mut acc = List::default();
172
173            for (row_idx, (self_row, other_row)) in
174                self.buffer.iter().zip(other.buffer.iter()).enumerate()
175            {
176                for (col_idx, (self_pixel_char, other_pixel_char)) in
177                    self_row.iter().zip(other_row.iter()).enumerate()
178                {
179                    if self_pixel_char != other_pixel_char {
180                        let pos = col(col_idx) + row(row_idx);
181                        acc.push((pos, *other_pixel_char));
182                    }
183                }
184            }
185            Some(PixelCharDiffChunks::from(acc))
186        }
187
188        /// Create a new buffer and fill it with empty chars.
189        #[must_use]
190        pub fn new_with_capacity_initialized(window_size: Size) -> Self {
191            let mut buffer = Self {
192                buffer: PixelCharLines::new_with_capacity_initialized(window_size),
193                window_size,
194                my_pos: Pos::default(),
195                my_fg_color: None,
196                my_bg_color: None,
197                memory_size_calc_cache: MemoizedMemorySize::default(),
198            };
199            // Explicitly calculate and cache the initial memory size.
200            // We know the cache is empty (invariant), so directly populate it.
201            let size = buffer.get_mem_size();
202            buffer
203                .memory_size_calc_cache
204                .upsert(|| MemorySize::new(size));
205            buffer
206        }
207
208        // Make sure each line is full of empty chars.
209        pub fn clear(&mut self) {
210            for line in self.buffer.iter_mut() {
211                for pixel_char in line.iter_mut() {
212                    if pixel_char != &PixelChar::Spacer {
213                        *pixel_char = PixelChar::Spacer;
214                    }
215                }
216            }
217            // Invalidate and recalculate cache when buffer is cleared.
218            self.invalidate_memory_size_calc_cache();
219        }
220    }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, Hash)]
224pub struct PixelCharLines {
225    pub lines: InlineVec<PixelCharLine>,
226}
227
228mod pixel_char_lines_impl {
229    #[allow(clippy::wildcard_imports)]
230    use super::*;
231
232    impl GetMemSize for PixelCharLines {
233        fn get_mem_size(&self) -> usize { get_mem_size::slice_size(self.lines.as_ref()) }
234    }
235
236    impl Deref for PixelCharLines {
237        type Target = InlineVec<PixelCharLine>;
238        fn deref(&self) -> &Self::Target { &self.lines }
239    }
240
241    impl DerefMut for PixelCharLines {
242        fn deref_mut(&mut self) -> &mut Self::Target { &mut self.lines }
243    }
244
245    impl PixelCharLines {
246        #[must_use]
247        pub fn new_with_capacity_initialized(window_size: Size) -> Self {
248            let window_height = window_size.row_height;
249            let window_width = window_size.col_width;
250            Self {
251                lines: smallvec![
252                    PixelCharLine::new_with_capacity_initialized(window_width);
253                    window_height.as_usize()
254                ],
255            }
256        }
257    }
258}
259
260#[derive(Clone, PartialEq, Eq, Hash)]
261pub struct PixelCharLine {
262    pub pixel_chars: Vec<PixelChar>,
263}
264
265impl GetMemSize for PixelCharLine {
266    fn get_mem_size(&self) -> usize {
267        get_mem_size::slice_size(self.pixel_chars.as_ref())
268    }
269}
270
271mod pixel_char_line_impl {
272    #[allow(clippy::wildcard_imports)]
273    use super::*;
274
275    impl Debug for PixelCharLine {
276        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277            // Pretty print only so many chars per line (depending on the terminal width
278            // in which log.fish is run).
279            const MAX_PIXEL_CHARS_PER_LINE: usize = 6;
280
281            let mut void_indices: InlineVec<usize> = smallvec![];
282            let mut spacer_indices: InlineVec<usize> = smallvec![];
283            let mut void_count: InlineVec<TinyInlineString> = smallvec![];
284            let mut spacer_count: InlineVec<TinyInlineString> = smallvec![];
285
286            let mut char_count = 0;
287
288            // Loop: for each PixelChar in a line (pixel_chars_lines[row_index]).
289            for (col_index, pixel_char) in self.iter().enumerate() {
290                match pixel_char {
291                    PixelChar::Void => {
292                        void_count.push(TinyInlineString::from(col_index.to_string()));
293                        void_indices.push(col_index);
294                    }
295                    PixelChar::Spacer => {
296                        spacer_count.push(TinyInlineString::from(col_index.to_string()));
297                        spacer_indices.push(col_index);
298                    }
299                    PixelChar::PlainText { .. } => {}
300                }
301
302                // Index message.
303                write!(
304                    f,
305                    "{}{:?}",
306                    dim_underline(&tiny_inline_string!("{col_index:03}")),
307                    pixel_char
308                )?;
309
310                // Add \n every MAX_CHARS_PER_LINE characters.
311                char_count += 1;
312                if char_count >= MAX_PIXEL_CHARS_PER_LINE {
313                    char_count = 0;
314                    writeln!(f)?;
315                }
316            }
317
318            // Pretty print the spacers & voids (of any of either or both) at the end of
319            // the output.
320            {
321                if !void_count.is_empty() {
322                    write!(f, "void [ ")?;
323                    fmt_impl_index_values(&void_indices, f)?;
324                    write!(f, " ]")?;
325
326                    // Add spacer divider if spacer count exists (next).
327                    if !spacer_count.is_empty() {
328                        write!(f, " | ")?;
329                    }
330                }
331
332                if !spacer_count.is_empty() {
333                    // Add comma divider if void count exists (previous).
334                    if !void_count.is_empty() {
335                        write!(f, ", ")?;
336                    }
337                    write!(f, "spacer [ ")?;
338                    fmt_impl_index_values(&spacer_indices, f)?;
339                    write!(f, " ]")?;
340                }
341            }
342
343            ok!()
344        }
345    }
346
347    fn fmt_impl_index_values(
348        values: &[usize],
349        f: &mut fmt::Formatter<'_>,
350    ) -> std::fmt::Result {
351        mod helpers {
352            pub enum Peek {
353                NextItemContinuesRange,
354                NextItemDoesNotContinueRange,
355            }
356
357            pub fn peek_does_next_item_continues_range(
358                values: &[usize],
359                index: usize,
360            ) -> Peek {
361                if values.get(index + 1).is_none() {
362                    return Peek::NextItemDoesNotContinueRange;
363                }
364                if values[index + 1] == values[index] + 1 {
365                    Peek::NextItemContinuesRange
366                } else {
367                    Peek::NextItemDoesNotContinueRange
368                }
369            }
370
371            pub enum CurrentRange {
372                DoesNotExist,
373                Exists,
374            }
375
376            pub fn does_current_range_exist(current_range: &[usize]) -> CurrentRange {
377                if current_range.is_empty() {
378                    CurrentRange::DoesNotExist
379                } else {
380                    CurrentRange::Exists
381                }
382            }
383        }
384
385        // Track state thru loop iteration.
386        let mut acc_current_range: InlineVec<usize> = smallvec![];
387
388        // Main loop.
389        for (index, value) in values.iter().enumerate() {
390            match (
391                helpers::peek_does_next_item_continues_range(values, index),
392                helpers::does_current_range_exist(&acc_current_range),
393            ) {
394                // Start new current range OR the next value continues the current range.
395                (
396                    helpers::Peek::NextItemContinuesRange,
397                    helpers::CurrentRange::DoesNotExist | helpers::CurrentRange::Exists,
398                ) => {
399                    acc_current_range.push(*value);
400                }
401                // The next value does not continue the current range & the current range
402                // does not exist.
403                (
404                    helpers::Peek::NextItemDoesNotContinueRange,
405                    helpers::CurrentRange::DoesNotExist,
406                ) => {
407                    if index > 0 {
408                        write!(f, ", ")?;
409                    }
410                    write!(f, "{value}")?;
411                }
412                // The next value does not continue the current range & the current range
413                // exists.
414                (
415                    helpers::Peek::NextItemDoesNotContinueRange,
416                    helpers::CurrentRange::Exists,
417                ) => {
418                    if index > 0 {
419                        write!(f, ", ")?;
420                    }
421                    acc_current_range.push(*value);
422                    write!(
423                        f,
424                        "{}-{}",
425                        acc_current_range[0],
426                        acc_current_range[acc_current_range.len() - 1]
427                    )?;
428                    acc_current_range.clear();
429                }
430            }
431        }
432
433        ok!()
434    }
435
436    // This represents a single row on the screen (i.e. a line of text).
437    impl PixelCharLine {
438        /// Create a new row with the given width and fill it with the empty chars.
439        #[must_use]
440        pub fn new_with_capacity_initialized(window_width: ColWidth) -> Self {
441            Self {
442                pixel_chars: vec![PixelChar::Spacer; window_width.as_usize()],
443            }
444        }
445    }
446
447    impl Deref for PixelCharLine {
448        type Target = Vec<PixelChar>;
449        fn deref(&self) -> &Self::Target { &self.pixel_chars }
450    }
451
452    impl DerefMut for PixelCharLine {
453        fn deref_mut(&mut self) -> &mut Self::Target { &mut self.pixel_chars }
454    }
455}
456
457#[derive(Clone, Copy, PartialEq, Eq, Hash)]
458pub enum PixelChar {
459    Void,
460    Spacer,
461    PlainText {
462        display_char: char,
463        maybe_style: Option<TuiStyle>,
464    },
465}
466
467impl GetMemSize for PixelChar {
468    fn get_mem_size(&self) -> usize {
469        // Since PixelChar is now Copy, its size is fixed
470        std::mem::size_of::<PixelChar>()
471    }
472}
473
474const EMPTY_CHAR: char = '╳';
475const VOID_CHAR: char = '❯';
476
477mod pixel_char_impl {
478    #[allow(clippy::wildcard_imports)]
479    use super::*;
480
481    impl Default for PixelChar {
482        fn default() -> Self { Self::Spacer }
483    }
484
485    impl Debug for PixelChar {
486        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
487            const WIDTH: usize = 16;
488
489            match self {
490                PixelChar::Void => {
491                    write!(f, " V {VOID_CHAR:░^WIDTH$}")?;
492                }
493                PixelChar::Spacer => {
494                    write!(f, " S {EMPTY_CHAR:░^WIDTH$}")?;
495                }
496                PixelChar::PlainText {
497                    display_char,
498                    maybe_style,
499                } => {
500                    match maybe_style {
501                        // Content + style.
502                        Some(style) => {
503                            write!(
504                                f,
505                                " {} '{display_char}'→{style: ^WIDTH$}",
506                                fg_magenta("P")
507                            )?;
508                        }
509                        // Content, no style.
510                        _ => {
511                            write!(f, " {} '{display_char}': ^WIDTH$", fg_magenta("P"))?;
512                        }
513                    }
514                }
515            }
516
517            ok!()
518        }
519    }
520}
521
522pub trait OffscreenBufferPaint {
523    fn render(&mut self, offscreen_buffer: &OffscreenBuffer) -> RenderOps;
524
525    fn render_diff(&mut self, diff_chunks: &PixelCharDiffChunks) -> RenderOps;
526
527    fn paint(
528        &mut self,
529        render_ops: RenderOps,
530        flush_kind: FlushKind,
531        window_size: Size,
532        locked_output_device: LockedOutputDevice<'_>,
533        is_mock: bool,
534    );
535
536    fn paint_diff(
537        &mut self,
538        render_ops: RenderOps,
539        window_size: Size,
540        locked_output_device: LockedOutputDevice<'_>,
541        is_mock: bool,
542    );
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use crate::{assert_eq2, height, new_style, tui_color, width};
549
550    #[test]
551    fn test_offscreen_buffer_construction() {
552        let window_size = width(10) + height(2);
553        let my_offscreen_buffer =
554            OffscreenBuffer::new_with_capacity_initialized(window_size);
555        assert_eq2!(my_offscreen_buffer.buffer.len(), 2);
556        assert_eq2!(my_offscreen_buffer.buffer[0].len(), 10);
557        assert_eq2!(my_offscreen_buffer.buffer[1].len(), 10);
558        for line in my_offscreen_buffer.buffer.iter() {
559            for pixel_char in line.iter() {
560                assert_eq2!(pixel_char, &PixelChar::Spacer);
561            }
562        }
563        // println!("my_offscreen_buffer: \n{:#?}", my_offscreen_buffer);
564    }
565
566    #[test]
567    fn test_offscreen_buffer_re_init() {
568        let window_size = width(10) + height(2);
569        let mut my_offscreen_buffer =
570            OffscreenBuffer::new_with_capacity_initialized(window_size);
571
572        my_offscreen_buffer.buffer[0][0] = PixelChar::PlainText {
573            display_char: 'a',
574            maybe_style: Some(new_style!(color_bg: {tui_color!(green)})),
575        };
576
577        my_offscreen_buffer.buffer[1][9] = PixelChar::PlainText {
578            display_char: 'z',
579            maybe_style: Some(new_style!(color_bg: {tui_color!(red)})),
580        };
581
582        // println!("my_offscreen_buffer: \n{:#?}", my_offscreen_buffer);
583        my_offscreen_buffer.clear();
584        for line in my_offscreen_buffer.buffer.iter() {
585            for pixel_char in line.iter() {
586                assert_eq2!(pixel_char, &PixelChar::Spacer);
587            }
588        }
589        // println!("my_offscreen_buffer: \n{:#?}", my_offscreen_buffer);
590    }
591
592    #[test]
593    fn test_memory_size_caching() {
594        let window_size = width(10) + height(2);
595        let mut my_offscreen_buffer =
596            OffscreenBuffer::new_with_capacity_initialized(window_size);
597
598        // First call should calculate and cache
599        let size1 = my_offscreen_buffer.get_mem_size_cached();
600        assert_ne!(format!("{size1}"), "?");
601
602        // Second call should use cached value (no recalculation)
603        let size2 = my_offscreen_buffer.get_mem_size_cached();
604        assert_eq!(format!("{size1}"), format!("{}", size2));
605
606        // Modify buffer through DerefMut (invalidates cache)
607        my_offscreen_buffer.buffer[0][0] = PixelChar::PlainText {
608            display_char: 'x',
609            maybe_style: None,
610        };
611
612        // Next call should recalculate
613        let size3 = my_offscreen_buffer.get_mem_size_cached();
614        assert_ne!(format!("{size3}"), "?");
615
616        // Clear should also invalidate cache
617        my_offscreen_buffer.clear();
618        let size4 = my_offscreen_buffer.get_mem_size_cached();
619        assert_ne!(format!("{size4}"), "?");
620    }
621}