Skip to main content

rlevo_core/render/
styled.rs

1//! Styled-output primitives consumed by the live TUI and report tiers.
2//!
3//! These types form the second projection of [`super::AsciiRenderable`]: the
4//! plain method returns a `String` for logs, snapshot tests, and
5//! `EpisodeRecord.ascii`, while the styled method returns a [`StyledFrame`]
6//! carrying foreground/background colour and modifier hints.
7//! The type set is intentionally a small subset of the ratatui vocabulary so
8//! that `rlevo-core` ships zero terminal-side dependencies — the
9//! `From<StyledFrame>` conversion into ratatui types lives in
10//! `rlevo-benchmarks::tui` (behind the `tui` feature), and the report tier
11//! deserialises [`StyledFrame`] from `EpisodeRecord`.
12//!
13//! No truecolor: stick to the 16-colour ANSI palette plus indexed 256-colour.
14//! See [`super::palette`] for the project-wide semantic constants every
15//! environment impl should use rather than reaching for raw [`Color`] values.
16
17use std::ops::{BitOr, BitOrAssign};
18
19use serde::{Deserialize, Serialize};
20
21/// A multi-line styled projection of an environment frame.
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23pub struct StyledFrame {
24    /// Lines in source order. Empty when the frame carries no content.
25    pub lines: Vec<StyledLine>,
26}
27
28impl StyledFrame {
29    /// Construct an unstyled frame from a plain string, splitting on `\n`.
30    ///
31    /// Every line becomes a single span with the default style. Used by the
32    /// default `AsciiRenderable::render_styled` impl so that environments
33    /// without bespoke colouring still produce a well-typed frame.
34    ///
35    /// # Examples
36    ///
37    /// ```
38    /// use rlevo_core::render::styled::StyledFrame;
39    ///
40    /// let frame = StyledFrame::unstyled("line one\nline two".to_string());
41    /// assert_eq!(frame.lines.len(), 2);
42    /// assert_eq!(frame.plain_text(), "line one\nline two");
43    /// ```
44    #[must_use]
45    pub fn unstyled(s: String) -> Self {
46        if s.is_empty() {
47            return Self { lines: Vec::new() };
48        }
49        let lines = s.split('\n').map(StyledLine::unstyled).collect();
50        Self { lines }
51    }
52
53    /// `true` when the frame contains no lines.
54    #[must_use]
55    pub fn is_empty(&self) -> bool {
56        self.lines.is_empty()
57    }
58
59    /// Concatenate every span's text across every line, separated by `\n`.
60    ///
61    /// Useful in tests to assert that the styled projection carries the same
62    /// glyphs as the plain projection (modulo trailing newlines).
63    #[must_use]
64    pub fn plain_text(&self) -> String {
65        let mut out = String::new();
66        for (i, line) in self.lines.iter().enumerate() {
67            if i > 0 {
68                out.push('\n');
69            }
70            for span in &line.spans {
71                out.push_str(&span.text);
72            }
73        }
74        out
75    }
76}
77
78/// A single line of styled spans.
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct StyledLine {
81    /// Spans in source order. Concatenating each span's text yields the plain
82    /// line content.
83    pub spans: Vec<StyledSpan>,
84}
85
86impl StyledLine {
87    /// Build a line carrying a single unstyled span.
88    #[must_use]
89    pub fn unstyled(s: impl Into<String>) -> Self {
90        Self {
91            spans: vec![StyledSpan {
92                text: s.into(),
93                style: SpanStyle::default(),
94            }],
95        }
96    }
97
98    /// Build a line from any iterable of spans.
99    ///
100    /// # Examples
101    ///
102    /// ```
103    /// use rlevo_core::render::styled::{Color, SpanStyle, StyledLine, StyledSpan};
104    ///
105    /// let spans = vec![
106    ///     StyledSpan::new("agent", SpanStyle::default().fg(Color::Cyan).bold()),
107    ///     StyledSpan::raw(" at (3, 4)"),
108    /// ];
109    /// let line = StyledLine::from_spans(spans);
110    /// assert_eq!(line.spans.len(), 2);
111    /// ```
112    #[must_use]
113    pub fn from_spans<I: IntoIterator<Item = StyledSpan>>(spans: I) -> Self {
114        Self {
115            spans: spans.into_iter().collect(),
116        }
117    }
118}
119
120/// A run of text with a uniform style.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct StyledSpan {
123    /// The text content. Never contains `\n` — line breaks are owned by the
124    /// enclosing [`StyledFrame`].
125    pub text: String,
126    /// Style applied to every character in `text`.
127    pub style: SpanStyle,
128}
129
130impl StyledSpan {
131    /// Build a span with the supplied text and style.
132    #[must_use]
133    pub fn new(text: impl Into<String>, style: SpanStyle) -> Self {
134        Self {
135            text: text.into(),
136            style,
137        }
138    }
139
140    /// Build an unstyled span.
141    #[must_use]
142    pub fn raw(text: impl Into<String>) -> Self {
143        Self::new(text, SpanStyle::default())
144    }
145}
146
147/// Foreground / background colour and modifier bits applied to a span.
148#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct SpanStyle {
150    /// Foreground colour. `None` means "use the terminal's default."
151    pub fg: Option<Color>,
152    /// Background colour. `None` means "use the terminal's default."
153    pub bg: Option<Color>,
154    /// Modifier bits (bold, italic, etc.) applied on top of the colour.
155    pub modifier: Modifier,
156}
157
158impl SpanStyle {
159    /// Set the foreground colour.
160    #[must_use]
161    pub const fn fg(mut self, c: Color) -> Self {
162        self.fg = Some(c);
163        self
164    }
165
166    /// Set the background colour.
167    #[must_use]
168    pub const fn bg(mut self, c: Color) -> Self {
169        self.bg = Some(c);
170        self
171    }
172
173    /// Add the `BOLD` modifier.
174    #[must_use]
175    pub const fn bold(mut self) -> Self {
176        self.modifier = self.modifier.union(Modifier::BOLD);
177        self
178    }
179
180    /// Add the `DIM` modifier.
181    #[must_use]
182    pub const fn dim(mut self) -> Self {
183        self.modifier = self.modifier.union(Modifier::DIM);
184        self
185    }
186
187    /// Add the `ITALIC` modifier.
188    #[must_use]
189    pub const fn italic(mut self) -> Self {
190        self.modifier = self.modifier.union(Modifier::ITALIC);
191        self
192    }
193
194    /// Add the `UNDERLINED` modifier.
195    #[must_use]
196    pub const fn underlined(mut self) -> Self {
197        self.modifier = self.modifier.union(Modifier::UNDERLINED);
198        self
199    }
200
201    /// Add the `REVERSED` modifier (swap foreground/background — pairs with
202    /// `HAZARD_FG` to give a hue-redundant signal for deuteranopic users).
203    #[must_use]
204    pub const fn reversed(mut self) -> Self {
205        self.modifier = self.modifier.union(Modifier::REVERSED);
206        self
207    }
208
209    /// Union an existing [`Modifier`] into the style.
210    #[must_use]
211    pub const fn with_modifier(mut self, m: Modifier) -> Self {
212        self.modifier = self.modifier.union(m);
213        self
214    }
215}
216
217/// 16-colour ANSI palette plus indexed 256-colour escape.
218///
219/// Truecolor (24-bit RGB) is intentionally absent — the spec restricts the
220/// library tier to the portable ANSI subset so that any compliant terminal
221/// renders the live TUI identically.
222#[non_exhaustive]
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
224pub enum Color {
225    /// Reset to the terminal's default colour.
226    Reset,
227    /// ANSI black (0).
228    Black,
229    /// ANSI red (1). Reserve for hazards via [`super::palette::HAZARD_FG`].
230    Red,
231    /// ANSI green (2). Reserve for goals via [`super::palette::GOAL_FG`].
232    Green,
233    /// ANSI yellow (3).
234    Yellow,
235    /// ANSI blue (4).
236    Blue,
237    /// ANSI magenta (5).
238    Magenta,
239    /// ANSI cyan (6).
240    Cyan,
241    /// ANSI bright black / gray (8).
242    Gray,
243    /// ANSI dim gray (the "bright black" alternate on some palettes).
244    DarkGray,
245    /// ANSI bright red (9).
246    LightRed,
247    /// ANSI bright green (10).
248    LightGreen,
249    /// ANSI bright yellow (11).
250    LightYellow,
251    /// ANSI bright blue (12).
252    LightBlue,
253    /// ANSI bright magenta (13).
254    LightMagenta,
255    /// ANSI bright cyan (14).
256    LightCyan,
257    /// ANSI white (15).
258    White,
259    /// Indexed 256-colour palette entry.
260    Indexed(u8),
261}
262
263/// Bitset of style modifiers (bold, italic, etc.).
264///
265/// Implemented as a plain `u8` rather than `bitflags!` to keep the crate's
266/// dependency cone untouched. The bit layout is private; use the named
267/// constants and `BitOr` operator to compose values.
268///
269/// Combining modifiers with `|` and testing membership with [`Modifier::contains`]
270/// is the intended composition pattern. Pair [`Modifier::REVERSED`] with a
271/// semantic colour from [`super::palette`] to satisfy the project's
272/// accessibility contract (color is never the sole distinguishing signal).
273///
274/// # Examples
275///
276/// ```
277/// use rlevo_core::render::styled::Modifier;
278///
279/// let m = Modifier::BOLD | Modifier::UNDERLINED;
280/// assert!(m.contains(Modifier::BOLD));
281/// assert!(m.contains(Modifier::UNDERLINED));
282/// assert!(!m.contains(Modifier::ITALIC));
283///
284/// // Hue-redundant hazard signal: combine REVERSED with a red foreground.
285/// let hazard = Modifier::BOLD | Modifier::REVERSED;
286/// assert!(hazard.contains(Modifier::REVERSED));
287/// ```
288#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
289pub struct Modifier(u8);
290
291impl Modifier {
292    /// No modifiers set.
293    pub const EMPTY: Self = Self(0);
294    /// Bold text.
295    pub const BOLD: Self = Self(1 << 0);
296    /// Dim / faint text.
297    pub const DIM: Self = Self(1 << 1);
298    /// Italic text.
299    pub const ITALIC: Self = Self(1 << 2);
300    /// Underlined text.
301    pub const UNDERLINED: Self = Self(1 << 3);
302    /// Reversed foreground/background. Pairs with `HAZARD_FG` to add the
303    /// hue-redundant signal required by the project accessibility contract.
304    pub const REVERSED: Self = Self(1 << 4);
305
306    /// `true` when every bit of `other` is set in `self`.
307    #[must_use]
308    pub const fn contains(self, other: Self) -> bool {
309        (self.0 & other.0) == other.0
310    }
311
312    /// Return the union of two modifier sets.
313    #[must_use]
314    pub const fn union(self, other: Self) -> Self {
315        Self(self.0 | other.0)
316    }
317
318    /// Return the intersection of two modifier sets.
319    #[must_use]
320    pub const fn intersection(self, other: Self) -> Self {
321        Self(self.0 & other.0)
322    }
323
324    /// Insert `other`'s bits in place.
325    pub const fn insert(&mut self, other: Self) {
326        self.0 |= other.0;
327    }
328
329    /// Clear `other`'s bits in place.
330    pub const fn remove(&mut self, other: Self) {
331        self.0 &= !other.0;
332    }
333
334    /// `true` when no bits are set.
335    #[must_use]
336    pub const fn is_empty(self) -> bool {
337        self.0 == 0
338    }
339
340    /// Raw bit value. Intended for renderer-side bit translation (e.g.,
341    /// mapping into `ratatui::style::Modifier`) — not part of the public
342    /// styling contract.
343    #[must_use]
344    pub const fn bits(self) -> u8 {
345        self.0
346    }
347}
348
349impl BitOr for Modifier {
350    type Output = Self;
351
352    fn bitor(self, rhs: Self) -> Self {
353        self.union(rhs)
354    }
355}
356
357impl BitOrAssign for Modifier {
358    fn bitor_assign(&mut self, rhs: Self) {
359        self.insert(rhs);
360    }
361}
362
363impl std::fmt::Debug for Modifier {
364    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365        let mut names: Vec<&'static str> = Vec::new();
366        if self.contains(Self::BOLD) {
367            names.push("BOLD");
368        }
369        if self.contains(Self::DIM) {
370            names.push("DIM");
371        }
372        if self.contains(Self::ITALIC) {
373            names.push("ITALIC");
374        }
375        if self.contains(Self::UNDERLINED) {
376            names.push("UNDERLINED");
377        }
378        if self.contains(Self::REVERSED) {
379            names.push("REVERSED");
380        }
381        if names.is_empty() {
382            write!(f, "Modifier::EMPTY")
383        } else {
384            write!(f, "Modifier({})", names.join(" | "))
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn styled_frame_unstyled_round_trip() {
395        let s = String::from("hello\nworld");
396        let frame = StyledFrame::unstyled(s.clone());
397        assert_eq!(frame.lines.len(), 2);
398        assert_eq!(frame.plain_text(), s);
399    }
400
401    #[test]
402    fn styled_frame_unstyled_empty() {
403        let frame = StyledFrame::unstyled(String::new());
404        assert!(frame.is_empty());
405        assert_eq!(frame.plain_text(), "");
406    }
407
408    #[test]
409    fn styled_frame_unstyled_single_line() {
410        let frame = StyledFrame::unstyled(String::from("solo"));
411        assert_eq!(frame.lines.len(), 1);
412        assert_eq!(frame.plain_text(), "solo");
413    }
414
415    #[test]
416    fn modifier_bitops() {
417        let bold_italic = Modifier::BOLD | Modifier::ITALIC;
418        assert!(bold_italic.contains(Modifier::BOLD));
419        assert!(bold_italic.contains(Modifier::ITALIC));
420        assert!(!bold_italic.contains(Modifier::REVERSED));
421
422        let intersection = bold_italic.intersection(Modifier::BOLD);
423        assert!(intersection.contains(Modifier::BOLD));
424        assert!(!intersection.contains(Modifier::ITALIC));
425
426        let mut m = Modifier::EMPTY;
427        m |= Modifier::UNDERLINED;
428        assert!(m.contains(Modifier::UNDERLINED));
429        m.remove(Modifier::UNDERLINED);
430        assert!(m.is_empty());
431    }
432
433    #[test]
434    fn spanstyle_builder_chain() {
435        let style: SpanStyle = SpanStyle::default()
436            .fg(Color::Cyan)
437            .bg(Color::Black)
438            .bold()
439            .reversed();
440        assert_eq!(style.fg, Some(Color::Cyan));
441        assert_eq!(style.bg, Some(Color::Black));
442        assert!(style.modifier.contains(Modifier::BOLD));
443        assert!(style.modifier.contains(Modifier::REVERSED));
444        assert!(!style.modifier.contains(Modifier::ITALIC));
445    }
446
447    #[test]
448    fn modifier_debug_lists_names() {
449        let m: Modifier = Modifier::BOLD | Modifier::REVERSED;
450        let s = format!("{m:?}");
451        assert!(s.contains("BOLD"));
452        assert!(s.contains("REVERSED"));
453    }
454
455    #[test]
456    fn modifier_debug_empty() {
457        let m = Modifier::EMPTY;
458        assert_eq!(format!("{m:?}"), "Modifier::EMPTY");
459    }
460}