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}