Skip to main content

tatara_ui/
render.rs

1//! ANSI renderer — turns an `EventStream` into colored text with Nord styling.
2//!
3//! Honors `NO_COLOR`, auto-detects non-tty streams, and always writes
4//! deterministic output (same events + same theme → identical bytes).
5
6use std::io::Write;
7
8use owo_colors::{OwoColorize, Style};
9
10use crate::event::{ArtifactState, Cell, EventStream, LogLevel, UiEvent};
11use crate::palette::{Rgb, Role, RoleMap};
12use crate::sigil::Sigil;
13
14/// Auto-detect whether to emit ANSI escapes.
15/// `true` if stderr is a tty and `NO_COLOR` is not set.
16pub fn should_color() -> bool {
17    if std::env::var_os("NO_COLOR").is_some() {
18        return false;
19    }
20    // Crate's unix-only isatty probe. Stays conservative on non-Unix.
21    #[cfg(unix)]
22    {
23        use std::os::fd::AsRawFd;
24        let fd = std::io::stderr().as_raw_fd();
25        // SAFETY: isatty is a libc call on an owned fd.
26        (unsafe { libc::isatty(fd) }) == 1
27    }
28    #[cfg(not(unix))]
29    {
30        true
31    }
32}
33
34pub struct Renderer {
35    pub role_map: RoleMap,
36    pub color: bool,
37}
38
39impl Default for Renderer {
40    fn default() -> Self {
41        Self::new(RoleMap::default())
42    }
43}
44
45impl Renderer {
46    pub fn new(role_map: RoleMap) -> Self {
47        Self {
48            role_map,
49            color: should_color(),
50        }
51    }
52
53    pub fn plain(role_map: RoleMap) -> Self {
54        Self {
55            role_map,
56            color: false,
57        }
58    }
59
60    pub fn with_color(mut self, on: bool) -> Self {
61        self.color = on;
62        self
63    }
64
65    pub fn render(&self, events: &EventStream, w: &mut impl Write) -> std::io::Result<()> {
66        for e in &events.events {
67            self.render_one(e, w)?;
68        }
69        Ok(())
70    }
71
72    pub fn render_one(&self, e: &UiEvent, w: &mut impl Write) -> std::io::Result<()> {
73        match e {
74            UiEvent::Banner { title, subtitle } => self.banner(title, subtitle.as_deref(), w),
75            UiEvent::Section { title } => self.section(title, w),
76            UiEvent::Log { level, message } => self.log(*level, message, w),
77            UiEvent::PhaseBegin { phase } => {
78                let arrow = self.glyph(Sigil::Arrow);
79                let phase_s = self.text(phase, Role::Primary);
80                writeln!(w, "  {arrow} {phase_s}")
81            }
82            UiEvent::PhaseEnd { phase, elapsed_ms } => {
83                let check = self.glyph(Sigil::Check);
84                let phase_s = self.text(phase, Role::Dim);
85                let elapsed = self.dim_elapsed(*elapsed_ms);
86                writeln!(w, "  {check} {phase_s} {elapsed}")
87            }
88            UiEvent::Artifact { name, hash, state } => self.artifact(name, hash, state, w),
89            UiEvent::Summary {
90                root_hash,
91                total,
92                built,
93                cached,
94                failed,
95            } => self.summary(root_hash, *total, *built, *cached, *failed, w),
96            UiEvent::Row { cells } => self.row(cells, w),
97        }
98    }
99
100    fn banner(
101        &self,
102        title: &str,
103        subtitle: Option<&str>,
104        w: &mut impl Write,
105    ) -> std::io::Result<()> {
106        let snow = self.glyph(Sigil::Snowflake);
107        let title_s = self.text(title, Role::Primary);
108        writeln!(w, "{snow} {title_s}")?;
109        if let Some(sub) = subtitle {
110            let sub_s = self.text(sub, Role::Dim);
111            writeln!(w, "  {sub_s}")?;
112        }
113        Ok(())
114    }
115
116    fn section(&self, title: &str, w: &mut impl Write) -> std::io::Result<()> {
117        let sec = self.glyph(Sigil::Section);
118        let title_s = self.text(title, Role::Primary);
119        writeln!(w)?;
120        writeln!(w, "{sec} {title_s}")?;
121        // Subtle Nord-dim underline
122        let line_char = "─";
123        let rule = line_char.repeat(2 + title.chars().count() + 1);
124        let rule_s = self.text(&rule, Role::Dim);
125        writeln!(w, "{rule_s}")?;
126        Ok(())
127    }
128
129    fn log(&self, level: LogLevel, message: &str, w: &mut impl Write) -> std::io::Result<()> {
130        let sigil = match level {
131            LogLevel::Success => Sigil::Check,
132            LogLevel::Error => Sigil::Cross,
133            LogLevel::Warn => Sigil::Tilde,
134            LogLevel::Info => Sigil::Dot,
135            LogLevel::Dim => Sigil::DotHollow,
136        };
137        let s = self.colored_glyph(sigil, level.role());
138        let msg = self.text(message, level.role());
139        writeln!(w, "  {s} {msg}")
140    }
141
142    fn artifact(
143        &self,
144        name: &str,
145        hash: &crate::event::ShortHash,
146        state: &ArtifactState,
147        w: &mut impl Write,
148    ) -> std::io::Result<()> {
149        // ❄ <name>  ◇blake3:cxx3i50  ⚡ cached | ⚙ built 5.3s | ○ pending | ✗ failed
150        let snow = self.colored_glyph(Sigil::Snowflake, Role::Primary);
151        let name_s = self.text(name, Role::Primary);
152        let diamond = self.colored_glyph(Sigil::Diamond, Role::Info);
153        let hash_s = self.text(&format!("blake3:{hash}"), Role::Dim);
154        let state_chunk = self.state_chunk(state);
155        writeln!(w, "  {snow} {name_s:<28} {diamond} {hash_s}  {state_chunk}")
156    }
157
158    fn state_chunk(&self, state: &ArtifactState) -> String {
159        match state {
160            ArtifactState::Built { elapsed_ms } => {
161                let g = self.colored_glyph(Sigil::Gear, Role::Warn);
162                let label = self.text("built", Role::Success);
163                let ms = self.text(&format!("{:.1}s", *elapsed_ms as f64 / 1000.0), Role::Dim);
164                format!("{g} {label} {ms}")
165            }
166            ArtifactState::Cached => {
167                let g = self.colored_glyph(Sigil::Lightning, Role::Success);
168                let label = self.text("cached", Role::Success);
169                format!("{g} {label}")
170            }
171            ArtifactState::Pending => {
172                let g = self.colored_glyph(Sigil::DotHollow, Role::Dim);
173                let label = self.text("pending", Role::Dim);
174                format!("{g} {label}")
175            }
176            ArtifactState::Failed { reason } => {
177                let g = self.colored_glyph(Sigil::Cross, Role::Error);
178                let label = self.text("failed", Role::Error);
179                let reason_s = self.text(reason, Role::Error);
180                format!("{g} {label} {reason_s}")
181            }
182        }
183    }
184
185    fn summary(
186        &self,
187        root_hash: &crate::event::ShortHash,
188        total: usize,
189        built: usize,
190        cached: usize,
191        failed: usize,
192        w: &mut impl Write,
193    ) -> std::io::Result<()> {
194        writeln!(w)?;
195        let tri = self.colored_glyph(Sigil::Triangle, Role::Primary);
196        let label = self.text("content root", Role::Primary);
197        let diamond = self.colored_glyph(Sigil::Diamond, Role::Info);
198        let hash = self.text(&format!("blake3:{root_hash}"), Role::Dim);
199        writeln!(w, "{tri} {label}  {diamond} {hash}")?;
200        let summary =
201            format!("  {total} total · {built} built · {cached} cached · {failed} failed",);
202        let role = if failed > 0 {
203            Role::Error
204        } else {
205            Role::Success
206        };
207        writeln!(w, "{}", self.text(&summary, role))?;
208        Ok(())
209    }
210
211    fn row(&self, cells: &[Cell], w: &mut impl Write) -> std::io::Result<()> {
212        let mut parts = Vec::with_capacity(cells.len());
213        for c in cells {
214            let role = c.role.unwrap_or(Role::Info);
215            parts.push(self.text(&c.text, role));
216        }
217        writeln!(w, "  {}", parts.join("  "))
218    }
219
220    // ── primitives ──────────────────────────────────────────────────────
221
222    fn glyph(&self, s: Sigil) -> String {
223        self.colored_glyph(s, s.default_role())
224    }
225
226    fn colored_glyph(&self, s: Sigil, r: Role) -> String {
227        self.text(s.glyph(), r)
228    }
229
230    fn text(&self, s: &str, role: Role) -> String {
231        if !self.color {
232            return s.to_string();
233        }
234        let rgb = self.role_map.color_of(role);
235        self.apply(s, rgb)
236    }
237
238    fn apply(&self, s: &str, rgb: Rgb) -> String {
239        // owo-colors uses a 24-bit truecolor sequence — works in any
240        // modern terminal (kitty, ghostty, iterm2, terminal.app 14+, tmux 3.2+).
241        let style = Style::new().color(rgb.owo());
242        s.style(style).to_string()
243    }
244
245    fn dim_elapsed(&self, ms: u64) -> String {
246        self.text(&format!("{:.1}s", ms as f64 / 1000.0), Role::Dim)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::event::ShortHash;
254
255    fn plain() -> Renderer {
256        Renderer::plain(RoleMap::default())
257    }
258
259    #[test]
260    fn banner_with_no_color_contains_plain_snowflake_and_title() {
261        let r = plain();
262        let mut out: Vec<u8> = Vec::new();
263        r.render_one(
264            &UiEvent::Banner {
265                title: "tatara-boot-gen".into(),
266                subtitle: Some("plex".into()),
267            },
268            &mut out,
269        )
270        .unwrap();
271        let s = String::from_utf8(out).unwrap();
272        assert!(s.contains('❄'));
273        assert!(s.contains("tatara-boot-gen"));
274        assert!(s.contains("plex"));
275    }
276
277    #[test]
278    fn artifact_renders_name_hash_state() {
279        let r = plain();
280        let mut out: Vec<u8> = Vec::new();
281        r.render_one(
282            &UiEvent::Artifact {
283                name: "initrd-plex".into(),
284                hash: ShortHash::from_blake3_hex("cxx3i50l"),
285                state: ArtifactState::Cached,
286            },
287            &mut out,
288        )
289        .unwrap();
290        let s = String::from_utf8(out).unwrap();
291        assert!(s.contains("initrd-plex"));
292        assert!(s.contains("blake3:cxx3i50"));
293        assert!(s.contains("cached"));
294    }
295
296    #[test]
297    fn summary_shows_totals_and_hash() {
298        let r = plain();
299        let mut out: Vec<u8> = Vec::new();
300        r.render_one(
301            &UiEvent::Summary {
302                root_hash: ShortHash::from_blake3_hex("abcd1234"),
303                total: 7,
304                built: 3,
305                cached: 4,
306                failed: 0,
307            },
308            &mut out,
309        )
310        .unwrap();
311        let s = String::from_utf8(out).unwrap();
312        assert!(s.contains("blake3:abcd123"));
313        assert!(s.contains("7 total"));
314        assert!(s.contains("3 built"));
315        assert!(s.contains("4 cached"));
316    }
317
318    #[test]
319    fn section_renders_divider_rule() {
320        let r = plain();
321        let mut out: Vec<u8> = Vec::new();
322        r.render_one(
323            &UiEvent::Section {
324                title: "synthesize".into(),
325            },
326            &mut out,
327        )
328        .unwrap();
329        let s = String::from_utf8(out).unwrap();
330        assert!(s.contains('⟡'));
331        assert!(s.contains("synthesize"));
332        assert!(s.contains('─'));
333    }
334
335    #[test]
336    fn log_sigils_match_level() {
337        let r = plain();
338        for (level, expected_glyph) in [
339            (LogLevel::Success, '✓'),
340            (LogLevel::Error, '✗'),
341            (LogLevel::Warn, '~'),
342        ] {
343            let mut out: Vec<u8> = Vec::new();
344            r.render_one(
345                &UiEvent::Log {
346                    level,
347                    message: "m".into(),
348                },
349                &mut out,
350            )
351            .unwrap();
352            let s = String::from_utf8(out).unwrap();
353            assert!(
354                s.contains(expected_glyph),
355                "{level:?} should use {expected_glyph}"
356            );
357        }
358    }
359}