Skip to main content

tui_lipan/widgets/ascii_canvas/
mod.rs

1//! ASCII canvas widget.
2//!
3//! `AsciiCanvas` is a versatile widget for displaying ASCII art, cell grids,
4//! and multi-frame sprite sheets. It supports:
5//!
6//! - **Static text lines** - simple rows of text
7//! - **Cell grids** - per-cell styled character buffers
8//! - **Frame sequences** - multi-frame displays with tag-based lookup
9//!   (sprite sheets, interactive animations, directional sprites)
10
11pub mod animation;
12
13mod layout;
14mod node;
15mod reconcile;
16
17pub(crate) use layout::measure_ascii_canvas;
18pub use node::AsciiCanvasNode;
19pub(crate) use reconcile::reconcile_ascii_canvas;
20
21use std::sync::Arc;
22
23use crate::core::element::{Element, ElementKind};
24use crate::style::{Length, Style};
25use crate::utils::gradient::{ColorGradient, GradientDirection};
26
27use self::animation::FrameSequence;
28
29/// A single cell in an ASCII canvas.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub struct AsciiCell {
32    /// Cell character.
33    pub ch: char,
34    /// Cell style.
35    pub style: Style,
36}
37
38impl Default for AsciiCell {
39    fn default() -> Self {
40        Self {
41            ch: ' ',
42            style: Style::default(),
43        }
44    }
45}
46
47impl AsciiCell {
48    /// Create a new cell with the given character.
49    pub fn new(ch: char) -> Self {
50        Self {
51            ch,
52            style: Style::default(),
53        }
54    }
55
56    /// Set the cell style.
57    pub fn style(mut self, style: Style) -> Self {
58        self.style = style;
59        self
60    }
61}
62
63impl From<char> for AsciiCell {
64    fn from(ch: char) -> Self {
65        Self::new(ch)
66    }
67}
68
69/// A mutable buffer for building an [`AsciiCanvas`].
70#[derive(Clone, Debug)]
71pub struct AsciiCanvasBuffer {
72    width: u16,
73    height: u16,
74    cells: Vec<AsciiCell>,
75}
76
77impl Default for AsciiCanvasBuffer {
78    fn default() -> Self {
79        Self::new(0, 0)
80    }
81}
82
83impl AsciiCanvasBuffer {
84    /// Create a new buffer filled with spaces.
85    pub fn new(width: u16, height: u16) -> Self {
86        let len = width as usize * height as usize;
87        Self {
88            width,
89            height,
90            cells: vec![AsciiCell::default(); len],
91        }
92    }
93
94    /// Buffer width in cells.
95    pub fn width(&self) -> u16 {
96        self.width
97    }
98
99    /// Buffer height in cells.
100    pub fn height(&self) -> u16 {
101        self.height
102    }
103
104    /// Fill the buffer with a single cell value.
105    pub fn fill(&mut self, cell: AsciiCell) {
106        self.cells.fill(cell);
107    }
108
109    /// Fill the buffer with a single character.
110    pub fn fill_char(&mut self, ch: char) {
111        self.fill(AsciiCell::new(ch));
112    }
113
114    /// Set a cell at a position.
115    pub fn set(&mut self, x: u16, y: u16, cell: AsciiCell) {
116        if x >= self.width || y >= self.height {
117            return;
118        }
119        let idx = (y as usize).saturating_mul(self.width as usize) + x as usize;
120        if let Some(slot) = self.cells.get_mut(idx) {
121            *slot = cell;
122        }
123    }
124
125    /// Set a character at a position.
126    pub fn set_char(&mut self, x: u16, y: u16, ch: char) {
127        self.set(x, y, AsciiCell::new(ch));
128    }
129
130    /// Borrow the cell slice.
131    pub fn cells(&self) -> &[AsciiCell] {
132        &self.cells
133    }
134
135    /// Collect all unique colors (both foreground and background) used in this
136    /// buffer, in order of first appearance.
137    ///
138    /// **Note:** if the same color value is used in both fg and bg, it appears
139    /// only once in the returned list.  When you need to map the same color
140    /// differently per channel, use [`collect_fg_colors`](Self::collect_fg_colors)
141    /// and [`collect_bg_colors`](Self::collect_bg_colors) instead.
142    pub fn collect_colors(&self) -> Vec<crate::style::Color> {
143        let mut seen = std::collections::HashSet::new();
144        let mut out = Vec::new();
145        for cell in &self.cells {
146            for color in [cell.style.fg, cell.style.bg]
147                .into_iter()
148                .flatten()
149                .map(crate::style::Paint::color)
150            {
151                if seen.insert(color) {
152                    out.push(color);
153                }
154            }
155        }
156        out
157    }
158
159    /// Collect all unique **foreground** colors used in this buffer, in order
160    /// of first appearance.
161    pub fn collect_fg_colors(&self) -> Vec<crate::style::Color> {
162        let mut seen = std::collections::HashSet::new();
163        let mut out = Vec::new();
164        for cell in &self.cells {
165            if let Some(color) = cell.style.fg.map(crate::style::Paint::color)
166                && seen.insert(color)
167            {
168                out.push(color);
169            }
170        }
171        out
172    }
173
174    /// Collect all unique **background** colors used in this buffer, in order
175    /// of first appearance.
176    pub fn collect_bg_colors(&self) -> Vec<crate::style::Color> {
177        let mut seen = std::collections::HashSet::new();
178        let mut out = Vec::new();
179        for cell in &self.cells {
180            if let Some(color) = cell.style.bg.map(crate::style::Paint::color)
181                && seen.insert(color)
182            {
183                out.push(color);
184            }
185        }
186        out
187    }
188}
189
190/// A 2D ASCII canvas element.
191///
192/// Supports three display modes:
193///
194/// 1. **Text lines** - created with [`AsciiCanvas::new`]
195/// 2. **Cell grid** - created with [`AsciiCanvas::from_cells`] or [`From<AsciiCanvasBuffer>`]
196/// 3. **Frame sequence** - created with [`AsciiCanvas::from_sequence`], displays one frame
197///    at a time from a [`FrameSequence`] (sprite sheet / animation)
198///
199/// # Examples
200///
201/// ```rust,ignore
202/// // Static text
203/// AsciiCanvas::new(["  ██  ", " ████ ", "██████"])
204///
205/// // Cell grid
206/// AsciiCanvas::from(buffer)
207///
208/// // Multi-frame sprite sheet
209/// AsciiCanvas::from_sequence(sequence)
210///     .frame_by_tag("direction", "left")
211///     .style(Style::new().fg(Color::White))
212/// ```
213#[derive(Clone)]
214pub struct AsciiCanvas {
215    /// Rows of text for the canvas.
216    pub lines: Vec<Arc<str>>,
217    /// Optional styled cells (row-major, len == width * height).
218    pub cells: Option<Arc<[AsciiCell]>>,
219    /// Explicit grid size for cell layouts.
220    pub grid_size: Option<(u16, u16)>,
221    /// Optional frame sequence for multi-frame display.
222    pub sequence: Option<Arc<FrameSequence>>,
223    /// Current frame index (only used when `sequence` is set).
224    pub current_frame: usize,
225    /// Base style applied to all cells.
226    pub style: Style,
227    /// Background fill style (only bg is respected).
228    pub background: Option<Style>,
229    /// Requested width.
230    /// Default: `Length::Auto`.
231    pub width: Length,
232    /// Requested height.
233    /// Default: `Length::Auto`.
234    pub height: Length,
235    /// Optional color gradient applied at render time.
236    pub gradient: Option<(ColorGradient, GradientDirection)>,
237    /// Optional color remapping applied at render time to **both** fg and bg
238    /// channels.
239    ///
240    /// Each entry maps a source color to a replacement color.  The mapping is
241    /// applied to both foreground and background channels - if a cell's fg or
242    /// bg matches a source entry, it is replaced with the corresponding target
243    /// color.  Colors not present in the map are rendered unchanged.
244    ///
245    /// When the same source color appears in both fg and bg but needs
246    /// different replacements, use [`fg_color_map`](Self::fg_color_map) and/or
247    /// [`bg_color_map`](Self::bg_color_map) instead (they take precedence over
248    /// this field for their respective channel).
249    ///
250    /// See [`FrameSequence::collect_colors`] to discover which colors an
251    /// asset uses.
252    pub color_map: Option<Arc<[(crate::style::Color, crate::style::Color)]>>,
253    /// Optional color remapping applied **only** to the foreground channel.
254    ///
255    /// Takes precedence over [`color_map`](Self::color_map) for fg lookups.
256    /// See [`FrameSequence::collect_fg_colors`] to discover fg-specific colors.
257    pub fg_color_map: Option<Arc<[(crate::style::Color, crate::style::Color)]>>,
258    /// Optional color remapping applied **only** to the background channel.
259    ///
260    /// Takes precedence over [`color_map`](Self::color_map) for bg lookups.
261    /// See [`FrameSequence::collect_bg_colors`] to discover bg-specific colors.
262    pub bg_color_map: Option<Arc<[(crate::style::Color, crate::style::Color)]>>,
263}
264
265impl Default for AsciiCanvas {
266    fn default() -> Self {
267        Self {
268            lines: Vec::new(),
269            cells: None,
270            grid_size: None,
271            sequence: None,
272            current_frame: 0,
273            style: Style::default(),
274            background: None,
275            width: Length::Auto,
276            height: Length::Auto,
277            gradient: None,
278            color_map: None,
279            fg_color_map: None,
280            bg_color_map: None,
281        }
282    }
283}
284
285impl AsciiCanvas {
286    /// Create a new canvas from text lines.
287    pub fn new(lines: impl IntoIterator<Item = impl Into<Arc<str>>>) -> Self {
288        Self {
289            lines: lines.into_iter().map(Into::into).collect(),
290            ..Self::default()
291        }
292    }
293
294    /// Create a blank canvas with an empty cell grid.
295    pub fn blank(width: u16, height: u16) -> Self {
296        let len = width as usize * height as usize;
297        Self::from_cells(width, height, vec![AsciiCell::default(); len])
298    }
299
300    /// Create a canvas by generating each cell from a callback.
301    pub fn with_cell_fn(width: u16, height: u16, mut f: impl FnMut(u16, u16) -> AsciiCell) -> Self {
302        let mut cells = Vec::with_capacity(width as usize * height as usize);
303        for y in 0..height {
304            for x in 0..width {
305                cells.push(f(x, y));
306            }
307        }
308        Self::from_cells(width, height, cells)
309    }
310
311    /// Create a canvas from a flat row-major grid of cells.
312    pub fn from_cells(width: u16, height: u16, cells: impl Into<Arc<[AsciiCell]>>) -> Self {
313        Self {
314            lines: Vec::new(),
315            cells: Some(cells.into()),
316            grid_size: Some((width, height)),
317            ..Self::default()
318        }
319    }
320
321    /// Create a canvas from a frame sequence (sprite sheet / animation).
322    ///
323    /// Displays the first frame by default. Use [`frame`](Self::frame) or
324    /// [`frame_by_tag`](Self::frame_by_tag) to select a specific frame.
325    pub fn from_sequence(sequence: Arc<FrameSequence>) -> Self {
326        Self {
327            sequence: Some(sequence),
328            ..Self::default()
329        }
330    }
331
332    /// Provide a flat row-major grid of cells.
333    pub fn cells(mut self, cells: impl Into<Arc<[AsciiCell]>>) -> Self {
334        self.cells = Some(cells.into());
335        self
336    }
337
338    /// Set the explicit grid size for cell rendering.
339    pub fn grid_size(mut self, width: u16, height: u16) -> Self {
340        self.grid_size = Some((width, height));
341        self
342    }
343
344    /// Set the current frame index (for sequence mode).
345    pub fn frame(mut self, idx: usize) -> Self {
346        if let Some(ref seq) = self.sequence {
347            self.current_frame = idx.min(seq.len().saturating_sub(1));
348        }
349        self
350    }
351
352    /// Set the current frame by tag lookup (for sequence mode).
353    ///
354    /// If no frame with the given tag is found, the current frame is unchanged.
355    pub fn frame_by_tag(mut self, key: &str, value: &str) -> Self {
356        if let Some(ref seq) = self.sequence
357            && let Some(idx) = seq.find_by_tag(key, value)
358        {
359            self.current_frame = idx;
360        }
361        self
362    }
363
364    /// Set the base style.
365    pub fn style(mut self, style: Style) -> Self {
366        self.style = style;
367        self
368    }
369
370    /// Set background style (only bg is used).
371    pub fn background(mut self, style: Style) -> Self {
372        self.background = Some(style);
373        self
374    }
375
376    /// Set requested width.
377    pub fn width(mut self, width: Length) -> Self {
378        self.width = width;
379        self
380    }
381
382    /// Set requested height.
383    pub fn height(mut self, height: Length) -> Self {
384        self.height = height;
385        self
386    }
387
388    /// Apply a color remapping over the rendered output to **both** fg and bg
389    /// channels.
390    ///
391    /// Each entry maps a source color to a replacement color.  The mapping is
392    /// applied to both foreground and background channels - if a cell's fg or
393    /// bg matches a source entry, it is replaced with the corresponding target
394    /// color.  Colors not present in the map are rendered unchanged.
395    ///
396    /// When the same source color appears in both fg and bg but needs
397    /// different replacements, use [`fg_color_map`](Self::fg_color_map) and/or
398    /// [`bg_color_map`](Self::bg_color_map) instead.
399    ///
400    /// Use [`FrameSequence::collect_colors`] or [`AsciiCanvasBuffer::collect_colors`]
401    /// to discover the colors an asset contains, then build a mapping from them
402    /// to your theme colors:
403    ///
404    /// ```rust,ignore
405    /// let colors = sequence.collect_colors(); // [Color::Red, Color::Blue]
406    /// let canvas = AsciiCanvas::from_sequence(arc_seq)
407    ///     .color_map(vec![
408    ///         (colors[0], theme.selection.fg.unwrap_or(Color::White)),
409    ///         (colors[1], theme.primary.fg.unwrap_or(Color::Gray)),
410    ///     ]);
411    /// ```
412    pub fn color_map(
413        mut self,
414        map: impl Into<Arc<[(crate::style::Color, crate::style::Color)]>>,
415    ) -> Self {
416        self.color_map = Some(map.into());
417        self
418    }
419
420    /// Apply a color remapping **only** to the foreground channel.
421    ///
422    /// Takes precedence over [`color_map`](Self::color_map) for fg lookups.
423    /// Use [`FrameSequence::collect_fg_colors`] to discover fg-specific colors.
424    ///
425    /// ```rust,ignore
426    /// let fg_colors = sequence.collect_fg_colors();
427    /// let bg_colors = sequence.collect_bg_colors();
428    /// let canvas = AsciiCanvas::from_sequence(arc_seq)
429    ///     .fg_color_map(vec![
430    ///         (fg_colors[0], Color::White),
431    ///         (fg_colors[1], Color::Gray),
432    ///     ])
433    ///     .bg_color_map(vec![
434    ///         (bg_colors[0], Color::Black),
435    ///     ]);
436    /// ```
437    pub fn fg_color_map(
438        mut self,
439        map: impl Into<Arc<[(crate::style::Color, crate::style::Color)]>>,
440    ) -> Self {
441        self.fg_color_map = Some(map.into());
442        self
443    }
444
445    /// Apply a color remapping **only** to the background channel.
446    ///
447    /// Takes precedence over [`color_map`](Self::color_map) for bg lookups.
448    /// See [`FrameSequence::collect_bg_colors`] to discover bg-specific colors.
449    pub fn bg_color_map(
450        mut self,
451        map: impl Into<Arc<[(crate::style::Color, crate::style::Color)]>>,
452    ) -> Self {
453        self.bg_color_map = Some(map.into());
454        self
455    }
456
457    /// Apply a color gradient over the rendered output.
458    ///
459    /// For cell-grid and sequence mode the gradient overrides each cell's
460    /// foreground base before per-cell style is patched on top, so cells with
461    /// an explicit `fg` color will still take priority.
462    ///
463    /// For text-lines mode the gradient is the sole foreground color.
464    pub fn gradient(mut self, gradient: ColorGradient, direction: GradientDirection) -> Self {
465        self.gradient = Some((gradient, direction));
466        self
467    }
468
469    /// Get the resolved cells and grid size for the current display mode.
470    ///
471    /// Returns `(cells, grid_width, grid_height)` if in cell/sequence mode,
472    /// or `None` if in text-lines mode.
473    pub fn resolved_cells(&self) -> Option<(&[AsciiCell], u16, u16)> {
474        if let Some(ref seq) = self.sequence {
475            let frame = seq.get(self.current_frame)?;
476            let buf = &frame.buffer;
477            Some((buf.cells(), buf.width(), buf.height()))
478        } else {
479            let cells = self.cells.as_ref()?;
480            let (w, h) = self.grid_size.unwrap_or((0, 0));
481            Some((cells, w, h))
482        }
483    }
484
485    /// Get the effective width of the content.
486    pub fn content_width(&self) -> u16 {
487        if let Some(ref seq) = self.sequence {
488            return seq.width();
489        }
490        if let Some((w, _)) = self.grid_size {
491            return w;
492        }
493        self.lines
494            .iter()
495            .map(|l| l.chars().count() as u16)
496            .max()
497            .unwrap_or(0)
498    }
499
500    /// Get the effective height of the content.
501    pub fn content_height(&self) -> u16 {
502        if let Some(ref seq) = self.sequence {
503            return seq.height();
504        }
505        if let Some((_, h)) = self.grid_size {
506            return h;
507        }
508        self.lines.len() as u16
509    }
510}
511
512impl From<AsciiCanvasBuffer> for AsciiCanvas {
513    fn from(buffer: AsciiCanvasBuffer) -> Self {
514        AsciiCanvas::from_cells(buffer.width, buffer.height, buffer.cells)
515    }
516}
517
518impl From<AsciiCanvas> for Element {
519    fn from(value: AsciiCanvas) -> Self {
520        Element::new(ElementKind::AsciiCanvas(value))
521    }
522}
523
524impl crate::layout::hash::LayoutHash for AsciiCanvas {
525    fn layout_hash(
526        &self,
527        hasher: &mut impl std::hash::Hasher,
528        _recurse: &dyn Fn(&crate::core::element::Element) -> Option<u64>,
529    ) -> Option<()> {
530        use std::hash::Hash;
531        self.width.hash(hasher);
532        self.height.hash(hasher);
533        self.grid_size.hash(hasher);
534
535        // Hash sequence identity + current frame for multi-frame mode
536        if let Some(ref seq) = self.sequence {
537            std::sync::Arc::as_ptr(seq).hash(hasher);
538            self.current_frame.hash(hasher);
539        }
540
541        let needs_content =
542            matches!(self.width, Length::Auto) || matches!(self.height, Length::Auto);
543        if needs_content {
544            self.lines.hash(hasher);
545            self.cells.hash(hasher);
546        }
547        Some(())
548    }
549}