Skip to main content

envision/component/big_text/
mod.rs

1//! A large pixel text component for dashboard hero numbers and KPI values.
2//!
3//! [`BigText`] renders text using large block characters where each character
4//! occupies 3 rows and a variable width. This is ideal for dashboard counters,
5//! clocks, and key performance indicators that need to stand out visually.
6//!
7//! State is stored in [`BigTextState`] and updated via [`BigTextMessage`].
8//!
9//! # Example
10//!
11//! ```rust
12//! use envision::component::{Component, BigText, BigTextState, BigTextMessage};
13//! use ratatui::prelude::*;
14//!
15//! let state = BigTextState::new("12:30")
16//!     .with_color(Color::Cyan)
17//!     .with_alignment(Alignment::Center);
18//!
19//! assert_eq!(state.text(), "12:30");
20//! assert_eq!(state.color(), Some(Color::Cyan));
21//! assert_eq!(state.alignment(), Alignment::Center);
22//! ```
23
24use ratatui::prelude::*;
25use ratatui::widgets::Paragraph;
26
27use super::{Component, EventContext, RenderContext};
28
29/// Returns the 3-row block representation of a character.
30///
31/// Each supported character is rendered as an array of 3 string slices,
32/// one per row. All rows for a given character have the same display width.
33///
34/// # Supported characters
35///
36/// - Digits: `0`-`9`
37/// - Punctuation: `.`, `:`, `-`, `/`, `%`, ` `
38/// - Uppercase letters: `A`-`Z`
39///
40/// Unsupported characters return a `?` placeholder glyph.
41///
42/// # Example
43///
44/// ```rust
45/// use envision::component::big_char;
46///
47/// let rows = big_char('0');
48/// assert_eq!(rows.len(), 3);
49/// assert_eq!(rows[0], "█▀█");
50/// assert_eq!(rows[1], "█ █");
51/// assert_eq!(rows[2], "▀▀▀");
52/// ```
53pub fn big_char(ch: char) -> [&'static str; 3] {
54    match ch {
55        '0' => ["█▀█", "█ █", "▀▀▀"],
56        '1' => ["▀█ ", " █ ", "▀▀▀"],
57        '2' => ["▀▀█", "█▀▀", "▀▀▀"],
58        '3' => ["▀▀█", " ▀█", "▀▀▀"],
59        '4' => ["█ █", "▀▀█", "  ▀"],
60        '5' => ["█▀▀", "▀▀█", "▀▀▀"],
61        '6' => ["█▀▀", "█▀█", "▀▀▀"],
62        '7' => ["▀▀█", "  █", "  ▀"],
63        '8' => ["█▀█", "█▀█", "▀▀▀"],
64        '9' => ["█▀█", "▀▀█", "▀▀▀"],
65        '.' => [" ", "▄", " "],
66        ':' => ["▄", " ", "▀"],
67        '-' => ["   ", "▀▀▀", "   "],
68        '/' => ["  █", " █ ", "█  "],
69        '%' => ["█ █", " █ ", "█ █"],
70        ' ' => ["   ", "   ", "   "],
71
72        'A' => ["█▀█", "█▀█", "▀ ▀"],
73        'B' => ["█▀▄", "█▀█", "▀▀▀"],
74        'C' => ["█▀▀", "█  ", "▀▀▀"],
75        'D' => ["█▀▄", "█ █", "▀▀▀"],
76        'E' => ["█▀▀", "█▀▀", "▀▀▀"],
77        'F' => ["█▀▀", "█▀ ", "▀  "],
78        'G' => ["█▀▀", "█▀█", "▀▀▀"],
79        'H' => ["█ █", "█▀█", "▀ ▀"],
80        'I' => ["▀█▀", " █ ", "▀▀▀"],
81        'J' => ["  █", "  █", "▀▀▀"],
82        'K' => ["█ █", "█▀▄", "▀ ▀"],
83        'L' => ["█  ", "█  ", "▀▀▀"],
84        'M' => ["█▄█", "█ █", "▀ ▀"],
85        'N' => ["█▀█", "█ █", "▀ ▀"],
86        'O' => ["█▀█", "█ █", "▀▀▀"],
87        'P' => ["█▀█", "█▀▀", "▀  "],
88        'Q' => ["█▀█", "█▄█", "▀▀▀"],
89        'R' => ["█▀█", "█▀▄", "▀ ▀"],
90        'S' => ["█▀▀", "▀▀█", "▀▀▀"],
91        'T' => ["▀█▀", " █ ", " ▀ "],
92        'U' => ["█ █", "█ █", "▀▀▀"],
93        'V' => ["█ █", "█ █", " ▀ "],
94        'W' => ["█ █", "█ █", "▀▄▀"],
95        'X' => ["█ █", " █ ", "█ █"],
96        'Y' => ["█ █", " █ ", " ▀ "],
97        'Z' => ["▀▀█", " █ ", "▀▀▀"],
98
99        _ => ["▀▀▀", " ▀ ", " ▀ "],
100    }
101}
102
103/// Returns the display width of a big character glyph.
104///
105/// This measures the Unicode display width of the first row of the
106/// character's 3-row representation.
107///
108/// # Example
109///
110/// ```rust
111/// use envision::component::big_char_width;
112///
113/// assert_eq!(big_char_width('0'), 3);
114/// assert_eq!(big_char_width('.'), 1);
115/// assert_eq!(big_char_width(':'), 1);
116/// ```
117pub fn big_char_width(ch: char) -> usize {
118    unicode_width::UnicodeWidthStr::width(big_char(ch)[0])
119}
120
121/// Messages that can be sent to a BigText component.
122#[derive(Clone, Debug, PartialEq)]
123pub enum BigTextMessage {
124    /// Replace the displayed text.
125    SetText(String),
126    /// Set the color override.
127    SetColor(Option<Color>),
128    /// Set the text alignment.
129    SetAlignment(Alignment),
130}
131
132/// State for a BigText component.
133///
134/// Contains the text to display in large block characters, along with
135/// optional color and alignment configuration.
136#[derive(Clone, Debug, PartialEq)]
137#[cfg_attr(
138    feature = "serialization",
139    derive(serde::Serialize, serde::Deserialize)
140)]
141pub struct BigTextState {
142    /// The text to render in large block characters.
143    text: String,
144    /// Optional color override for the text.
145    color: Option<Color>,
146    /// Text alignment within the render area.
147    #[cfg_attr(feature = "serialization", serde(skip))]
148    alignment: Alignment,
149}
150
151impl Default for BigTextState {
152    fn default() -> Self {
153        Self {
154            text: String::new(),
155            color: None,
156            alignment: Alignment::Center,
157        }
158    }
159}
160
161impl BigTextState {
162    /// Creates a new BigText state with the given text.
163    ///
164    /// The default alignment is center and no color override is applied.
165    ///
166    /// # Example
167    ///
168    /// ```rust
169    /// use envision::component::BigTextState;
170    ///
171    /// let state = BigTextState::new("42");
172    /// assert_eq!(state.text(), "42");
173    /// assert_eq!(state.color(), None);
174    /// ```
175    pub fn new(text: impl Into<String>) -> Self {
176        Self {
177            text: text.into(),
178            ..Self::default()
179        }
180    }
181
182    // ---- Builders ----
183
184    /// Sets the color override (builder pattern).
185    ///
186    /// # Example
187    ///
188    /// ```rust
189    /// use envision::component::BigTextState;
190    /// use ratatui::style::Color;
191    ///
192    /// let state = BigTextState::new("99")
193    ///     .with_color(Color::Green);
194    /// assert_eq!(state.color(), Some(Color::Green));
195    /// ```
196    pub fn with_color(mut self, color: Color) -> Self {
197        self.color = Some(color);
198        self
199    }
200
201    /// Sets the text alignment (builder pattern).
202    ///
203    /// # Example
204    ///
205    /// ```rust
206    /// use envision::component::BigTextState;
207    /// use ratatui::prelude::Alignment;
208    ///
209    /// let state = BigTextState::new("OK")
210    ///     .with_alignment(Alignment::Left);
211    /// assert_eq!(state.alignment(), Alignment::Left);
212    /// ```
213    pub fn with_alignment(mut self, alignment: Alignment) -> Self {
214        self.alignment = alignment;
215        self
216    }
217
218    // ---- Getters ----
219
220    /// Returns the text being displayed.
221    ///
222    /// # Example
223    ///
224    /// ```rust
225    /// use envision::component::BigTextState;
226    ///
227    /// let state = BigTextState::new("123");
228    /// assert_eq!(state.text(), "123");
229    /// ```
230    pub fn text(&self) -> &str {
231        &self.text
232    }
233
234    /// Returns the optional color override.
235    ///
236    /// # Example
237    ///
238    /// ```rust
239    /// use envision::component::BigTextState;
240    ///
241    /// let state = BigTextState::new("0");
242    /// assert_eq!(state.color(), None);
243    /// ```
244    pub fn color(&self) -> Option<Color> {
245        self.color
246    }
247
248    /// Returns the text alignment.
249    ///
250    /// # Example
251    ///
252    /// ```rust
253    /// use envision::component::BigTextState;
254    /// use ratatui::prelude::Alignment;
255    ///
256    /// let state = BigTextState::new("0");
257    /// assert_eq!(state.alignment(), Alignment::Center);
258    /// ```
259    pub fn alignment(&self) -> Alignment {
260        self.alignment
261    }
262
263    // ---- Setters ----
264
265    /// Sets the text to display.
266    ///
267    /// # Example
268    ///
269    /// ```rust
270    /// use envision::component::BigTextState;
271    ///
272    /// let mut state = BigTextState::new("old");
273    /// state.set_text("new");
274    /// assert_eq!(state.text(), "new");
275    /// ```
276    pub fn set_text(&mut self, text: impl Into<String>) {
277        self.text = text.into();
278    }
279
280    /// Sets the color override.
281    ///
282    /// # Example
283    ///
284    /// ```rust
285    /// use envision::component::BigTextState;
286    /// use ratatui::style::Color;
287    ///
288    /// let mut state = BigTextState::new("0");
289    /// state.set_color(Some(Color::Red));
290    /// assert_eq!(state.color(), Some(Color::Red));
291    /// ```
292    pub fn set_color(&mut self, color: Option<Color>) {
293        self.color = color;
294    }
295
296    /// Sets the text alignment.
297    ///
298    /// # Example
299    ///
300    /// ```rust
301    /// use envision::component::BigTextState;
302    /// use ratatui::prelude::Alignment;
303    ///
304    /// let mut state = BigTextState::new("0");
305    /// state.set_alignment(Alignment::Right);
306    /// assert_eq!(state.alignment(), Alignment::Right);
307    /// ```
308    pub fn set_alignment(&mut self, alignment: Alignment) {
309        self.alignment = alignment;
310    }
311
312    // ---- Instance methods ----
313
314    /// Maps an input event to a message (instance method).
315    ///
316    /// BigText is display-only, so this always returns `None`.
317    ///
318    /// # Example
319    ///
320    /// ```rust
321    /// use envision::component::BigTextState;
322    /// use envision::input::{Event, Key};
323    ///
324    /// let state = BigTextState::new("42");
325    /// assert_eq!(state.handle_event(&Event::key(Key::Enter)), None);
326    /// ```
327    pub fn handle_event(&self, event: &crate::input::Event) -> Option<BigTextMessage> {
328        BigText::handle_event(self, event, &EventContext::default())
329    }
330
331    /// Updates the state with a message, returning any output (instance method).
332    ///
333    /// # Example
334    ///
335    /// ```rust
336    /// use envision::component::BigTextState;
337    /// use envision::component::BigTextMessage;
338    ///
339    /// let mut state = BigTextState::new("old");
340    /// state.update(BigTextMessage::SetText("new".to_string()));
341    /// assert_eq!(state.text(), "new");
342    /// ```
343    pub fn update(&mut self, msg: BigTextMessage) -> Option<()> {
344        BigText::update(self, msg)
345    }
346
347    /// Dispatches an event by mapping and updating (instance method).
348    ///
349    /// BigText is display-only, so this always returns `None`.
350    ///
351    /// # Example
352    ///
353    /// ```rust
354    /// use envision::component::BigTextState;
355    /// use envision::input::{Event, Key};
356    ///
357    /// let mut state = BigTextState::new("42");
358    /// assert_eq!(state.dispatch_event(&Event::key(Key::Enter)), None);
359    /// ```
360    pub fn dispatch_event(&mut self, event: &crate::input::Event) -> Option<()> {
361        BigText::dispatch_event(self, event, &EventContext::default())
362    }
363}
364
365/// Builds the rendered content for one row of big text.
366///
367/// Given a text string and a row index (0-2), this concatenates the
368/// appropriate row slice from each character's big font glyph, with a
369/// single space separator between characters.
370fn build_row(text: &str, row: usize) -> String {
371    let mut result = String::new();
372    let chars: Vec<char> = text.chars().collect();
373    for (i, &ch) in chars.iter().enumerate() {
374        if i > 0 {
375            result.push(' ');
376        }
377        let glyph = big_char(ch.to_ascii_uppercase());
378        result.push_str(glyph[row]);
379    }
380    result
381}
382
383/// A large pixel text component for dashboard hero numbers and KPI values.
384///
385/// Renders text using large 3-row block characters. This is a display-only
386/// component and does not handle interactive events.
387///
388/// # Example
389///
390/// ```rust
391/// use envision::component::{Component, BigText, BigTextState};
392/// use ratatui::prelude::*;
393///
394/// let state = BigTextState::new("99.9%")
395///     .with_color(Color::Green)
396///     .with_alignment(Alignment::Center);
397///
398/// assert_eq!(state.text(), "99.9%");
399/// ```
400pub struct BigText;
401
402impl Component for BigText {
403    type State = BigTextState;
404    type Message = BigTextMessage;
405    type Output = ();
406
407    fn init() -> Self::State {
408        BigTextState::default()
409    }
410
411    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
412        match msg {
413            BigTextMessage::SetText(text) => state.text = text,
414            BigTextMessage::SetColor(color) => state.color = color,
415            BigTextMessage::SetAlignment(alignment) => state.alignment = alignment,
416        }
417        None
418    }
419
420    fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
421        crate::annotation::with_registry(|reg| {
422            reg.register(
423                ctx.area,
424                crate::annotation::Annotation::big_text("big_text")
425                    .with_label(&state.text)
426                    .with_disabled(ctx.disabled),
427            );
428        });
429
430        if ctx.area.height == 0 || ctx.area.width == 0 {
431            return;
432        }
433
434        let style = if ctx.disabled {
435            ctx.theme.disabled_style()
436        } else if let Some(color) = state.color {
437            Style::default().fg(color)
438        } else {
439            ctx.theme.normal_style()
440        };
441
442        // Build the 3 rows of big text
443        let lines: Vec<Line<'_>> = (0..3)
444            .map(|row| {
445                let row_text = build_row(&state.text, row);
446                Line::from(Span::styled(row_text, style))
447            })
448            .collect();
449
450        // Vertically center within the available ctx.area
451        let content_height: u16 = 3;
452        let vertical_offset = ctx.area.height.saturating_sub(content_height) / 2;
453
454        let render_area = Rect::new(
455            ctx.area.x,
456            ctx.area.y + vertical_offset,
457            ctx.area.width,
458            content_height.min(ctx.area.height.saturating_sub(vertical_offset)),
459        );
460
461        if render_area.height > 0 {
462            let paragraph = Paragraph::new(lines).alignment(state.alignment);
463            ctx.frame.render_widget(paragraph, render_area);
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests;