Skip to main content

qframe/
style.rs

1//! Drawing styles: what a cell looks like, and theme styles resolved for one frame.
2
3use ratatui_core::buffer::Cell;
4use ratatui_core::style::{Color, Modifier};
5
6use crate::color::{ColorDepth, Rgb};
7use crate::geometry::Padding;
8use crate::theme::{Paint, PropValue, StyleProps};
9
10/// Colours and attributes of drawn text. `None` colours keep what is already underneath.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub struct CellStyle {
13    /// Text colour.
14    pub fg: Option<Rgb>,
15    /// Background colour.
16    pub bg: Option<Rgb>,
17    /// Bold.
18    pub bold: bool,
19    /// Italic.
20    pub italic: bool,
21    /// Underlined.
22    pub underline: bool,
23    /// Faint.
24    pub dim: bool,
25}
26
27impl CellStyle {
28    /// A style with only a text colour.
29    #[must_use]
30    pub fn fg(color: Rgb) -> Self {
31        Self { fg: Some(color), ..Self::default() }
32    }
33
34    /// Replaces the background colour.
35    #[must_use]
36    pub fn on(mut self, color: Rgb) -> Self {
37        self.bg = Some(color);
38        self
39    }
40
41    /// Turns bold on or off.
42    #[must_use]
43    pub fn with_bold(mut self, bold: bool) -> Self {
44        self.bold = bold;
45        self
46    }
47
48    /// Writes this style into `cell`, reducing colours to `depth`.
49    #[cfg(test)]
50    pub(crate) fn apply(self, cell: &mut Cell, depth: ColorDepth) {
51        self.for_depth(depth).apply(cell);
52    }
53
54    /// This style with its colours reduced to `depth`, for writing into many cells.
55    pub(crate) fn for_depth(self, depth: ColorDepth) -> CellPaint {
56        let mut modifier = Modifier::empty();
57        modifier.set(Modifier::BOLD, self.bold);
58        modifier.set(Modifier::ITALIC, self.italic);
59        modifier.set(Modifier::UNDERLINED, self.underline);
60        modifier.set(Modifier::DIM, self.dim);
61        CellPaint { fg: self.fg.map(|fg| to_color(fg, depth)), bg: self.bg.map(|bg| to_color(bg, depth)), modifier }
62    }
63}
64
65/// A [`CellStyle`] with its colours already reduced to the terminal's depth. Reducing a colour
66/// to 256 or 16 colours searches a palette, which is worth doing once per text rather than once
67/// per cell.
68#[derive(Debug, Clone, Copy)]
69pub(crate) struct CellPaint {
70    fg: Option<Color>,
71    bg: Option<Color>,
72    modifier: Modifier,
73}
74
75impl CellPaint {
76    /// Writes this style into `cell`; `None` colours keep what is there.
77    pub(crate) fn apply(self, cell: &mut Cell) {
78        if let Some(fg) = self.fg {
79            cell.fg = fg;
80        }
81        if let Some(bg) = self.bg {
82            cell.bg = bg;
83        }
84        cell.modifier = self.modifier;
85    }
86}
87
88/// Converts a colour for a terminal of `depth`.
89pub(crate) fn to_color(color: Rgb, depth: ColorDepth) -> Color {
90    match depth {
91        ColorDepth::TrueColor => Color::Rgb(color.r, color.g, color.b),
92        ColorDepth::Ansi256 => Color::Indexed(color.to_ansi256()),
93        ColorDepth::Ansi16 => Color::Indexed(color.to_ansi16()),
94    }
95}
96
97/// A theme style resolved for the current frame: pulses are evaluated at one phase.
98#[derive(Debug, Clone, PartialEq)]
99pub struct WidgetStyle {
100    props: StyleProps,
101    phase: f32,
102}
103
104impl WidgetStyle {
105    pub(crate) fn new(props: StyleProps, phase: f32) -> Self {
106        Self { props, phase }
107    }
108
109    /// This style without `key`, e.g. a selected row that shares the selection tone but leaves the
110    /// pillar to the row that has the cursor.
111    #[must_use]
112    pub(crate) fn without(mut self, key: &str) -> Self {
113        self.props.remove(key);
114        self
115    }
116
117    /// The colour stored under `key` (`fg`, `bg`, `pillar`, `track`, ...).
118    #[must_use]
119    pub fn color(&self, key: &str) -> Option<Rgb> {
120        self.props.paint(key).map(|paint: Paint| paint.at(self.phase))
121    }
122
123    /// A flag such as `bold`; `false` when unset.
124    #[must_use]
125    pub fn flag(&self, key: &str) -> bool {
126        self.props.flag(key)
127    }
128
129    /// A cell count such as `gap`.
130    #[must_use]
131    pub fn cells(&self, key: &str) -> Option<u16> {
132        self.props.cells(key)
133    }
134
135    /// A word such as a scrollbar `style`.
136    #[must_use]
137    pub fn word(&self, key: &str) -> Option<&'static str> {
138        self.props.word(key)
139    }
140
141    /// Padding from `padding = [vertical, horizontal]` or `padding = n`; zero when unset.
142    #[must_use]
143    pub fn padding(&self) -> Padding {
144        match self.props.get("padding") {
145            Some(PropValue::Pair(v, h)) => Padding::symmetric(v, h),
146            Some(PropValue::Cells(n)) => Padding::all(n),
147            _ => Padding::default(),
148        }
149    }
150
151    /// The text style: `fg`, `bg`, `bold`, `italic`, `underline`, `dim`.
152    #[must_use]
153    pub fn text(&self) -> CellStyle {
154        CellStyle {
155            fg: self.color("fg"),
156            bg: self.color("bg"),
157            bold: self.flag("bold"),
158            italic: self.flag("italic"),
159            underline: self.flag("underline"),
160            dim: self.flag("dim"),
161        }
162    }
163
164    /// Whether drawing this style needs animation frames.
165    #[must_use]
166    pub fn is_animated(&self) -> bool {
167        self.props.is_animated()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::theme::ThemeRegistry;
175
176    #[test]
177    fn applies_colours_for_each_depth() {
178        let mut cell = Cell::default();
179        CellStyle::fg(Rgb::new(255, 0, 0))
180            .on(Rgb::new(0, 0, 0))
181            .with_bold(true)
182            .apply(&mut cell, ColorDepth::TrueColor);
183        assert_eq!(cell.fg, Color::Rgb(255, 0, 0));
184        assert!(cell.modifier.contains(Modifier::BOLD));
185        CellStyle::fg(Rgb::new(255, 0, 0)).apply(&mut cell, ColorDepth::Ansi256);
186        assert_eq!(cell.fg, Color::Indexed(196));
187        assert_eq!(cell.bg, Color::Rgb(0, 0, 0));
188        assert!(!cell.modifier.contains(Modifier::BOLD));
189    }
190
191    #[test]
192    fn resolves_theme_properties() {
193        let (theme, _) = ThemeRegistry::builtin().resolve_or_default("monochrome");
194        let style = WidgetStyle::new(theme.style("button", Some("primary"), &[]), 0.0);
195        // Primary rests on a tint of the accent, never the full fill, so hover and press can rise.
196        assert_ne!(style.text().bg, theme.color("accent"));
197        assert_eq!(style.text().fg, theme.color("accent"));
198        assert!(style.text().bold);
199        assert_eq!(style.padding(), Padding::symmetric(0, 2));
200    }
201}