Skip to main content

oino_ui/
style.rs

1//! Colour tokens (§5), modifiers and [`Style`] plus the ratatui-mirroring [`Stylize`] trait.
2//!
3//! Colours are theme role tokens only — no hex, no RGB, no ANSI indices (§4.2).
4//! Unknown token strings parse tolerantly into [`Color::Other`]; renderers drop them
5//! (with a diagnostic, §4.8) instead of rejecting the tree.
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9/// Theme role token (ui-protocol v2 §5). The closed set of tokens plus [`Color::Other`]
10/// for tolerant parsing of unknown names.
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12pub enum Color {
13    /// Clear inherited colour / terminal default.
14    Reset,
15    /// Body text.
16    Text,
17    /// Secondary text.
18    Muted,
19    /// Hints, timestamps.
20    Dim,
21    /// Highlights, active marks (claw orange in the app).
22    Accent,
23    /// Primary action, focus (shell red; never for errors).
24    Primary,
25    /// Information, links.
26    Info,
27    /// Alias of [`Color::Info`] (links).
28    Link,
29    /// Done, ok.
30    Success,
31    /// Warnings.
32    Warning,
33    /// Errors.
34    Error,
35    /// Harness / provenance rails.
36    Harness,
37    /// Thinking text.
38    Thinking,
39    /// Tool titles.
40    Tool,
41    /// Message-role tint (user).
42    User,
43    /// Message-role tint (assistant).
44    Assistant,
45    /// Inline code.
46    Code,
47    /// Borders.
48    Border,
49    /// Focused / accent borders.
50    BorderAccent,
51    /// Quiet borders.
52    BorderMuted,
53    /// Categorical palette slots 1..12.
54    C1,
55    /// Categorical palette slots 1..12.
56    C2,
57    /// Categorical palette slots 1..12.
58    C3,
59    /// Categorical palette slots 1..12.
60    C4,
61    /// Categorical palette slots 1..12.
62    C5,
63    /// Categorical palette slots 1..12.
64    C6,
65    /// Categorical palette slots 1..12.
66    C7,
67    /// Categorical palette slots 1..12.
68    C8,
69    /// Categorical palette slots 1..12.
70    C9,
71    /// Categorical palette slots 1..12.
72    C10,
73    /// Categorical palette slots 1..12.
74    C11,
75    /// Categorical palette slots 1..12.
76    C12,
77    /// Panels, inputs.
78    Surface1,
79    /// Panels, inputs.
80    Surface2,
81    /// Selection.
82    Surface3,
83    /// Selected rows.
84    SelectedBg,
85    /// Tinted rows (success).
86    SuccessBg,
87    /// Tinted rows (error).
88    ErrorBg,
89    /// Tinted rows (warning).
90    WarningBg,
91    /// Tinted rows (info).
92    InfoBg,
93    /// Tinted rows (accent).
94    AccentBg,
95    /// Diff-like added text.
96    DiffAdded,
97    /// Diff-like removed text.
98    DiffRemoved,
99    /// Diff-like context text.
100    DiffContext,
101    /// Unknown token: renderers drop it (with a diagnostic) instead of rejecting (§4.8).
102    Other(String),
103}
104
105/// The 44 normative token names, in Appendix A order.
106pub const COLOR_TOKENS: [&str; 44] = [
107    "reset",
108    "text",
109    "muted",
110    "dim",
111    "accent",
112    "primary",
113    "info",
114    "link",
115    "success",
116    "warning",
117    "error",
118    "harness",
119    "thinking",
120    "tool",
121    "user",
122    "assistant",
123    "code",
124    "border",
125    "borderAccent",
126    "borderMuted",
127    "c1",
128    "c2",
129    "c3",
130    "c4",
131    "c5",
132    "c6",
133    "c7",
134    "c8",
135    "c9",
136    "c10",
137    "c11",
138    "c12",
139    "surface1",
140    "surface2",
141    "surface3",
142    "selectedBg",
143    "successBg",
144    "errorBg",
145    "warningBg",
146    "infoBg",
147    "accentBg",
148    "diffAdded",
149    "diffRemoved",
150    "diffContext",
151];
152
153impl Color {
154    /// The wire name of a known token, or `None` for [`Color::Other`].
155    #[must_use]
156    pub fn as_str(&self) -> Option<&str> {
157        let name = match self {
158            Self::Reset => "reset",
159            Self::Text => "text",
160            Self::Muted => "muted",
161            Self::Dim => "dim",
162            Self::Accent => "accent",
163            Self::Primary => "primary",
164            Self::Info => "info",
165            Self::Link => "link",
166            Self::Success => "success",
167            Self::Warning => "warning",
168            Self::Error => "error",
169            Self::Harness => "harness",
170            Self::Thinking => "thinking",
171            Self::Tool => "tool",
172            Self::User => "user",
173            Self::Assistant => "assistant",
174            Self::Code => "code",
175            Self::Border => "border",
176            Self::BorderAccent => "borderAccent",
177            Self::BorderMuted => "borderMuted",
178            Self::C1 => "c1",
179            Self::C2 => "c2",
180            Self::C3 => "c3",
181            Self::C4 => "c4",
182            Self::C5 => "c5",
183            Self::C6 => "c6",
184            Self::C7 => "c7",
185            Self::C8 => "c8",
186            Self::C9 => "c9",
187            Self::C10 => "c10",
188            Self::C11 => "c11",
189            Self::C12 => "c12",
190            Self::Surface1 => "surface1",
191            Self::Surface2 => "surface2",
192            Self::Surface3 => "surface3",
193            Self::SelectedBg => "selectedBg",
194            Self::SuccessBg => "successBg",
195            Self::ErrorBg => "errorBg",
196            Self::WarningBg => "warningBg",
197            Self::InfoBg => "infoBg",
198            Self::AccentBg => "accentBg",
199            Self::DiffAdded => "diffAdded",
200            Self::DiffRemoved => "diffRemoved",
201            Self::DiffContext => "diffContext",
202            Self::Other(_) => return None,
203        };
204        Some(name)
205    }
206
207    /// Parses a known token; unknown names become [`Color::Other`].
208    #[must_use]
209    pub fn parse(name: &str) -> Self {
210        Self::parse_known(name).unwrap_or_else(|| Self::Other(name.to_owned()))
211    }
212
213    fn parse_known(name: &str) -> Option<Self> {
214        Some(match name {
215            "reset" => Self::Reset,
216            "text" => Self::Text,
217            "muted" => Self::Muted,
218            "dim" => Self::Dim,
219            "accent" => Self::Accent,
220            "primary" => Self::Primary,
221            "info" => Self::Info,
222            "link" => Self::Link,
223            "success" => Self::Success,
224            "warning" => Self::Warning,
225            "error" => Self::Error,
226            "harness" => Self::Harness,
227            "thinking" => Self::Thinking,
228            "tool" => Self::Tool,
229            "user" => Self::User,
230            "assistant" => Self::Assistant,
231            "code" => Self::Code,
232            "border" => Self::Border,
233            "borderAccent" => Self::BorderAccent,
234            "borderMuted" => Self::BorderMuted,
235            "c1" => Self::C1,
236            "c2" => Self::C2,
237            "c3" => Self::C3,
238            "c4" => Self::C4,
239            "c5" => Self::C5,
240            "c6" => Self::C6,
241            "c7" => Self::C7,
242            "c8" => Self::C8,
243            "c9" => Self::C9,
244            "c10" => Self::C10,
245            "c11" => Self::C11,
246            "c12" => Self::C12,
247            "surface1" => Self::Surface1,
248            "surface2" => Self::Surface2,
249            "surface3" => Self::Surface3,
250            "selectedBg" => Self::SelectedBg,
251            "successBg" => Self::SuccessBg,
252            "errorBg" => Self::ErrorBg,
253            "warningBg" => Self::WarningBg,
254            "infoBg" => Self::InfoBg,
255            "accentBg" => Self::AccentBg,
256            "diffAdded" => Self::DiffAdded,
257            "diffRemoved" => Self::DiffRemoved,
258            "diffContext" => Self::DiffContext,
259            _ => return None,
260        })
261    }
262
263    /// True when this is [`Color::Other`] (not a known token).
264    #[must_use]
265    pub fn is_unknown(&self) -> bool {
266        matches!(self, Self::Other(_))
267    }
268}
269
270impl Serialize for Color {
271    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
272        if let Self::Other(name) = self {
273            return serializer.serialize_str(name);
274        }
275        serializer.serialize_str(self.as_str().unwrap_or_default())
276    }
277}
278
279impl<'de> Deserialize<'de> for Color {
280    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
281        let name = String::deserialize(deserializer)?;
282        Ok(Self::parse(&name))
283    }
284}
285
286/// A text modifier (ratatui constant names, §4.2).
287#[allow(
288    non_camel_case_types,
289    reason = "wire names are ratatui's constant names (§4.2)"
290)]
291#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
292pub enum Modifier {
293    /// Bold.
294    BOLD,
295    /// Dim / half bright.
296    DIM,
297    /// Italic.
298    ITALIC,
299    /// Underlined.
300    UNDERLINED,
301    /// Slow blink.
302    SLOW_BLINK,
303    /// Rapid blink.
304    RAPID_BLINK,
305    /// Reversed (swap fg/bg).
306    REVERSED,
307    /// Hidden.
308    HIDDEN,
309    /// Crossed out.
310    CROSSED_OUT,
311}
312
313/// All modifiers, in canonical (wire) order.
314pub const ALL_MODIFIERS: [Modifier; 9] = [
315    Modifier::BOLD,
316    Modifier::DIM,
317    Modifier::ITALIC,
318    Modifier::UNDERLINED,
319    Modifier::SLOW_BLINK,
320    Modifier::RAPID_BLINK,
321    Modifier::REVERSED,
322    Modifier::HIDDEN,
323    Modifier::CROSSED_OUT,
324];
325
326/// A bitflags-like set of [`Modifier`]s. Serializes as an array of modifier names
327/// in canonical order; an absent/empty set serializes to nothing.
328#[derive(Clone, Copy, Default, Debug, PartialEq, Eq, Hash)]
329pub struct Modifiers(u16);
330
331impl Modifiers {
332    /// Empty set.
333    #[must_use]
334    pub fn new() -> Self {
335        Self(0)
336    }
337
338    /// Adds a modifier.
339    pub fn insert(&mut self, m: Modifier) {
340        self.0 |= 1 << (m as u16);
341    }
342
343    /// Removes a modifier.
344    pub fn remove(&mut self, m: Modifier) {
345        self.0 &= !(1 << (m as u16));
346    }
347
348    /// True when the modifier is set.
349    #[must_use]
350    pub fn contains(&self, m: Modifier) -> bool {
351        self.0 & (1 << (m as u16)) != 0
352    }
353
354    /// True when no modifier is set.
355    #[must_use]
356    pub fn is_empty(&self) -> bool {
357        self.0 == 0
358    }
359
360    /// Union of both sets.
361    #[must_use]
362    pub fn union(mut self, other: Self) -> Self {
363        self.0 |= other.0;
364        self
365    }
366
367    /// Iterates the set in canonical order.
368    pub fn iter(&self) -> impl Iterator<Item = Modifier> + '_ {
369        ALL_MODIFIERS.into_iter().filter(|m| self.contains(*m))
370    }
371
372    /// Builds a set from an iterator.
373    #[must_use]
374    pub fn from_iter_const<I: IntoIterator<Item = Modifier>>(mods: I) -> Self {
375        let mut set = Self::new();
376        for m in mods {
377            set.insert(m);
378        }
379        set
380    }
381}
382
383impl Serialize for Modifiers {
384    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
385        serializer.collect_seq(self.iter())
386    }
387}
388
389impl<'de> Deserialize<'de> for Modifiers {
390    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
391        let mods = Vec::<Modifier>::deserialize(deserializer)?;
392        Ok(Self::from_iter_const(mods))
393    }
394}
395
396/// ratatui `Style` with theme-token colours (§4.2). Composition follows ratatui
397/// `Style::patch`: colours override when set, modifiers accumulate, `subModifier`
398/// removes inherited modifiers.
399#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase", default)]
401pub struct Style {
402    /// Foreground token.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub fg: Option<Color>,
405    /// Background token.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub bg: Option<Color>,
408    /// Underline colour token.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub underline_color: Option<Color>,
411    /// Modifiers to add on top of inherited ones.
412    #[serde(skip_serializing_if = "Modifiers::is_empty")]
413    pub add_modifier: Modifiers,
414    /// Modifiers to remove from inherited ones.
415    #[serde(skip_serializing_if = "Modifiers::is_empty")]
416    pub sub_modifier: Modifiers,
417}
418
419impl Style {
420    /// Empty style.
421    #[must_use]
422    pub fn new() -> Self {
423        Self {
424            fg: None,
425            bg: None,
426            underline_color: None,
427            add_modifier: Modifiers::new(),
428            sub_modifier: Modifiers::new(),
429        }
430    }
431
432    /// Sets the foreground token.
433    #[must_use]
434    pub fn fg(mut self, color: Color) -> Self {
435        self.fg = Some(color);
436        self
437    }
438
439    /// Sets the background token.
440    #[must_use]
441    pub fn bg(mut self, color: Color) -> Self {
442        self.bg = Some(color);
443        self
444    }
445
446    /// Sets the underline colour token.
447    #[must_use]
448    pub fn underline_color(mut self, color: Color) -> Self {
449        self.underline_color = Some(color);
450        self
451    }
452
453    /// Adds modifiers.
454    #[must_use]
455    pub fn add_modifier(mut self, m: impl IntoIterator<Item = Modifier>) -> Self {
456        self.add_modifier = self.add_modifier.union(Modifiers::from_iter_const(m));
457        self
458    }
459
460    /// Removes inherited modifiers.
461    #[must_use]
462    pub fn sub_modifier(mut self, m: impl IntoIterator<Item = Modifier>) -> Self {
463        self.sub_modifier = self.sub_modifier.union(Modifiers::from_iter_const(m));
464        self
465    }
466
467    /// ratatui `Style::patch`: `other` wins for colours, modifiers accumulate.
468    #[must_use]
469    pub fn patch(mut self, other: Self) -> Self {
470        if other.fg.is_some() {
471            self.fg = other.fg;
472        }
473        if other.bg.is_some() {
474            self.bg = other.bg;
475        }
476        if other.underline_color.is_some() {
477            self.underline_color = other.underline_color;
478        }
479        self.add_modifier = self.add_modifier.union(other.add_modifier);
480        self.sub_modifier = self.sub_modifier.union(other.sub_modifier);
481        self
482    }
483
484    /// True when nothing is set.
485    #[must_use]
486    pub fn is_empty(&self) -> bool {
487        self.fg.is_none()
488            && self.bg.is_none()
489            && self.underline_color.is_none()
490            && self.add_modifier.is_empty()
491            && self.sub_modifier.is_empty()
492    }
493}
494
495/// ratatui-mirroring `Stylize`: chainable style helpers on [`Span`], [`Line`] and
496/// [`Text`], and on `&str` / `String` (converting to a [`Span`]).
497///
498/// ```
499/// use oino_ui::prelude::*;
500/// let span = "Board ".bold().fg(Color::Accent);
501/// let line = Span::from("hi").dim();
502/// ```
503pub trait Stylize<T>: Sized {
504    /// Applies a style.
505    fn style(self, style: Style) -> T;
506
507    /// Sets the foreground token.
508    fn fg(self, color: Color) -> T {
509        self.style(Style::new().fg(color))
510    }
511
512    /// Sets the background token.
513    fn bg(self, color: Color) -> T {
514        self.style(Style::new().bg(color))
515    }
516
517    /// Sets the underline colour token.
518    fn underline_color(self, color: Color) -> T {
519        self.style(Style::new().underline_color(color))
520    }
521
522    /// Adds the `BOLD` modifier.
523    fn bold(self) -> T {
524        self.style(Style::new().add_modifier([Modifier::BOLD]))
525    }
526
527    /// Adds the `DIM` modifier.
528    fn dim(self) -> T {
529        self.style(Style::new().add_modifier([Modifier::DIM]))
530    }
531
532    /// Adds the `ITALIC` modifier.
533    fn italic(self) -> T {
534        self.style(Style::new().add_modifier([Modifier::ITALIC]))
535    }
536
537    /// Adds the `UNDERLINED` modifier.
538    fn underlined(self) -> T {
539        self.style(Style::new().add_modifier([Modifier::UNDERLINED]))
540    }
541
542    /// Adds the `SLOW_BLINK` modifier.
543    fn slow_blink(self) -> T {
544        self.style(Style::new().add_modifier([Modifier::SLOW_BLINK]))
545    }
546
547    /// Adds the `RAPID_BLINK` modifier.
548    fn rapid_blink(self) -> T {
549        self.style(Style::new().add_modifier([Modifier::RAPID_BLINK]))
550    }
551
552    /// Adds the `REVERSED` modifier.
553    fn reversed(self) -> T {
554        self.style(Style::new().add_modifier([Modifier::REVERSED]))
555    }
556
557    /// Adds the `HIDDEN` modifier.
558    fn hidden(self) -> T {
559        self.style(Style::new().add_modifier([Modifier::HIDDEN]))
560    }
561
562    /// Adds the `CROSSED_OUT` modifier.
563    fn crossed_out(self) -> T {
564        self.style(Style::new().add_modifier([Modifier::CROSSED_OUT]))
565    }
566}