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;