Skip to main content

qframe/
style.rs

1//! Drawing styles: what a cell looks like, and theme styles resolved for one frame.
2
3use std::collections::HashMap;
4
5use ratatui_core::buffer::{Buffer, Cell};
6use ratatui_core::style::{Color, Modifier};
7
8use crate::color::{ColorDepth, Rgb};
9use crate::geometry::Padding;
10use crate::theme::{Paint, PropValue, StyleProps};
11
12/// Colours and attributes of drawn text. `None` colours keep what is already underneath.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub struct CellStyle {
15    /// Text colour.
16    pub fg: Option<Rgb>,
17    /// Background colour.
18    pub bg: Option<Rgb>,
19    /// Bold.
20    pub bold: bool,
21    /// Italic.
22    pub italic: bool,
23    /// Underlined.
24    pub underline: bool,
25    /// Faint.
26    pub dim: bool,
27}
28
29impl CellStyle {
30    /// A style with only a text colour.
31    #[must_use]
32    pub fn fg(color: Rgb) -> Self {
33        Self { fg: Some(color), ..Self::default() }
34    }
35
36    /// Replaces the background colour.
37    #[must_use]
38    pub fn on(mut self, color: Rgb) -> Self {
39        self.bg = Some(color);
40        self
41    }
42
43    /// Turns bold on or off.
44    #[must_use]
45    pub fn with_bold(mut self, bold: bool) -> Self {
46        self.bold = bold;
47        self
48    }
49
50    /// Writes this style into `cell`.
51    #[cfg(test)]
52    pub(crate) fn apply(self, cell: &mut Cell) {
53        self.paint().apply(cell);
54    }
55
56    /// This style as cell colours and modifiers, for writing into many cells.
57    pub(crate) fn paint(self) -> CellPaint {
58        let mut modifier = Modifier::empty();
59        modifier.set(Modifier::BOLD, self.bold);
60        modifier.set(Modifier::ITALIC, self.italic);
61        modifier.set(Modifier::UNDERLINED, self.underline);
62        modifier.set(Modifier::DIM, self.dim);
63        CellPaint { fg: self.fg.map(to_color), bg: self.bg.map(to_color), modifier }
64    }
65}
66
67/// A [`CellStyle`] as the colours and modifiers a cell carries, worked out once per text rather
68/// than once per cell.
69#[derive(Debug, Clone, Copy)]
70pub(crate) struct CellPaint {
71    fg: Option<Color>,
72    bg: Option<Color>,
73    modifier: Modifier,
74}
75
76impl CellPaint {
77    /// Writes this style into `cell`; `None` colours keep what is there.
78    pub(crate) fn apply(self, cell: &mut Cell) {
79        if let Some(fg) = self.fg {
80            cell.fg = fg;
81        }
82        if let Some(bg) = self.bg {
83            cell.bg = bg;
84        }
85        cell.modifier = self.modifier;
86    }
87}
88
89/// Converts a colour for painting a frame.
90///
91/// Every frame is painted in full colour; one below true colour is reduced to its palette once
92/// it is complete, by [`reduce`]. Blending (a dimmed screen behind a dialog, a lifted menu, a page
93/// sliding in) needs the full colours to work on, and which palette entry a colour takes can
94/// depend on the theme's ground and, for text, on the colour behind it.
95pub(crate) fn to_color(color: Rgb) -> Color {
96    Color::Rgb(color.r, color.g, color.b)
97}
98
99/// Reduces a frame painted in full colour to the palette of `depth`, on a screen whose ground is
100/// `ground`; a true-colour frame is left as it is. Cells already in palette colours are left as
101/// they are.
102///
103/// In sixteen colours backgrounds take [`Rgb::to_ansi16_on`] and text [`Rgb::to_ansi16_text`]
104/// against the background of its own cell; in 256 colours backgrounds take [`Rgb::to_ansi256`]
105/// and text [`Rgb::to_ansi256_text`]. A half block (`▀`, `▄`) in 256 colours is a fill rather than
106/// text, two pixels of a picture or of big letters, so both its halves take [`Rgb::to_ansi256`]:
107/// pushing the upper half away from the lower one to keep it readable would streak a smooth
108/// picture wherever two neighbouring pixels are close.
109pub(crate) fn reduce(buf: &mut Buffer, depth: ColorDepth, ground: Rgb) {
110    match depth {
111        ColorDepth::TrueColor => {}
112        ColorDepth::Ansi256 => reduce_with(buf, Rgb::to_ansi256, Rgb::to_ansi256_text, true),
113        ColorDepth::Ansi16 => {
114            let text = |text: Rgb, bg| text.to_ansi16_text(bg, ground);
115            reduce_with(buf, |tone| tone.to_ansi16_on(ground), text, false);
116        }
117    }
118}
119
120/// Whether `symbol` is a half block, which splits its cell into two areas of colour.
121fn is_half_block(symbol: &str) -> bool {
122    matches!(symbol, "▀" | "▄")
123}
124
125/// Reduces every full-colour cell of `buf`: backgrounds and glyphless text by `tone`, the text of
126/// a glyph by `text` against its cell's background. With `half_blocks_fill`, a half block counts
127/// as glyphless.
128fn reduce_with(buf: &mut Buffer, tone: impl Fn(Rgb) -> u8, text: impl Fn(Rgb, Rgb) -> u8, half_blocks_fill: bool) {
129    // A frame holds few distinct colours and many cells; each reduction searches the palette once.
130    // Neighbouring cells mostly share their colours, so the last cell's answer is tried first.
131    let mut tones: HashMap<Rgb, u8> = HashMap::new();
132    let mut texts: HashMap<(Rgb, Rgb), u8> = HashMap::new();
133    let mut last: Option<(CellColours, (Color, Color))> = None;
134    for cell in &mut buf.content {
135        let (bg, fg) = (rgb(cell.bg), rgb(cell.fg));
136        if bg.is_none() && fg.is_none() {
137            continue;
138        }
139        let symbol = cell.symbol();
140        let glyph =
141            fg.is_some() && bg.is_some() && !symbol.trim().is_empty() && !(half_blocks_fill && is_half_block(symbol));
142        let key = (cell.fg, cell.bg, glyph);
143        if let Some((seen, (fg, bg))) = last
144            && seen == key
145        {
146            (cell.fg, cell.bg) = (fg, bg);
147            continue;
148        }
149        if let Some(fg) = fg {
150            let index = match bg {
151                Some(bg) if glyph => *texts.entry((fg, bg)).or_insert_with(|| text(fg, bg)),
152                _ => *tones.entry(fg).or_insert_with(|| tone(fg)),
153            };
154            cell.fg = Color::Indexed(index);
155        }
156        if let Some(bg) = bg {
157            cell.bg = Color::Indexed(*tones.entry(bg).or_insert_with(|| tone(bg)));
158        }
159        last = Some((key, (cell.fg, cell.bg)));
160    }
161}
162
163/// A cell's text and background colours as painted, and whether it holds a glyph.
164type CellColours = (Color, Color, bool);
165
166/// The colour of a cell painted in full colour.
167fn rgb(color: Color) -> Option<Rgb> {
168    match color {
169        Color::Rgb(r, g, b) => Some(Rgb::new(r, g, b)),
170        _ => None,
171    }
172}
173
174/// A theme style resolved for the current frame: pulses are evaluated at one phase.
175#[derive(Debug, Clone, PartialEq)]
176pub struct WidgetStyle {
177    props: StyleProps,
178    phase: f32,
179}
180
181impl WidgetStyle {
182    pub(crate) fn new(props: StyleProps, phase: f32) -> Self {
183        Self { props, phase }
184    }
185
186    /// This style without `key`, e.g. a selected row that shares the selection tone but leaves the
187    /// pillar to the row that has the cursor.
188    #[must_use]
189    pub(crate) fn without(mut self, key: &str) -> Self {
190        self.props.remove(key);
191        self
192    }
193
194    /// The colour stored under `key` (`fg`, `bg`, `pillar`, `track`, ...).
195    #[must_use]
196    pub fn color(&self, key: &str) -> Option<Rgb> {
197        self.props.paint(key).map(|paint: Paint| paint.at(self.phase))
198    }
199
200    /// A flag such as `bold`; `false` when unset.
201    #[must_use]
202    pub fn flag(&self, key: &str) -> bool {
203        self.props.flag(key)
204    }
205
206    /// A cell count such as `gap`.
207    #[must_use]
208    pub fn cells(&self, key: &str) -> Option<u16> {
209        self.props.cells(key)
210    }
211
212    /// A word such as a scrollbar `style`.
213    #[must_use]
214    pub fn word(&self, key: &str) -> Option<&'static str> {
215        self.props.word(key)
216    }
217
218    /// Padding from `padding = [vertical, horizontal]` or `padding = n`; zero when unset.
219    #[must_use]
220    pub fn padding(&self) -> Padding {
221        match self.props.get("padding") {
222            Some(PropValue::Pair(v, h)) => Padding::symmetric(v, h),
223            Some(PropValue::Cells(n)) => Padding::all(n),
224            _ => Padding::default(),
225        }
226    }
227
228    /// The text style: `fg`, `bg`, `bold`, `italic`, `underline`, `dim`.
229    #[must_use]
230    pub fn text(&self) -> CellStyle {
231        CellStyle {
232            fg: self.color("fg"),
233            bg: self.color("bg"),
234            bold: self.flag("bold"),
235            italic: self.flag("italic"),
236            underline: self.flag("underline"),
237            dim: self.flag("dim"),
238        }
239    }
240
241    /// Whether drawing this style needs animation frames.
242    #[must_use]
243    pub fn is_animated(&self) -> bool {
244        self.props.is_animated()
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::theme::ThemeRegistry;
252
253    #[test]
254    fn applies_colours_and_modifiers() {
255        let mut cell = Cell::default();
256        CellStyle::fg(Rgb::new(255, 0, 0)).on(Rgb::new(0, 0, 0)).with_bold(true).apply(&mut cell);
257        assert_eq!(cell.fg, Color::Rgb(255, 0, 0));
258        assert!(cell.modifier.contains(Modifier::BOLD));
259        CellStyle::fg(Rgb::new(0, 0, 255)).apply(&mut cell);
260        assert_eq!(cell.fg, Color::Rgb(0, 0, 255));
261        assert_eq!(cell.bg, Color::Rgb(0, 0, 0), "a style without a background keeps the one there");
262        assert!(!cell.modifier.contains(Modifier::BOLD));
263    }
264
265    #[test]
266    fn a_frame_is_reduced_to_its_palette_once_painted() {
267        let ground = Rgb::new(12, 12, 14);
268        let painted = || {
269            let mut buf = Buffer::empty(ratatui_core::layout::Rect::new(0, 0, 3, 1));
270            CellStyle::fg(Rgb::new(255, 0, 0)).on(Rgb::new(0, 0, 0)).apply(&mut buf.content[0]);
271            buf.content[0].set_symbol("a");
272            buf.content[1].set_bg(Color::Indexed(4));
273            buf.content[2].set_bg(Color::Rgb(128, 128, 128));
274            buf
275        };
276        let mut buf = painted();
277        reduce(&mut buf, ColorDepth::TrueColor, ground);
278        assert_eq!(buf, painted(), "true colour is sent as painted");
279        reduce(&mut buf, ColorDepth::Ansi256, ground);
280        assert_eq!((buf.content[0].fg, buf.content[0].bg), (Color::Indexed(196), Color::Indexed(16)));
281        assert_eq!(buf.content[1].bg, Color::Indexed(4), "a palette colour is left as it is");
282        assert_eq!(buf.content[2].bg, Color::Indexed(244));
283        let mut buf = painted();
284        reduce(&mut buf, ColorDepth::Ansi16, ground);
285        assert_eq!((buf.content[0].fg, buf.content[0].bg), (Color::Indexed(9), Color::Indexed(0)));
286        assert_eq!(buf.content[2].bg, Color::Indexed(8));
287    }
288
289    #[test]
290    fn a_half_block_in_256_colours_is_two_fills_not_text() {
291        let (top, bottom) = (Rgb::new(120, 120, 120), Rgb::new(124, 124, 124));
292        let painted = |symbol: &str| {
293            let mut buf = Buffer::empty(ratatui_core::layout::Rect::new(0, 0, 1, 1));
294            CellStyle::fg(top).on(bottom).apply(&mut buf.content[0]);
295            buf.content[0].set_symbol(symbol);
296            buf
297        };
298        let mut buf = painted("▀");
299        reduce(&mut buf, ColorDepth::Ansi256, Rgb::new(12, 12, 14));
300        assert_eq!(buf.content[0].fg, Color::Indexed(top.to_ansi256()), "the upper half is its nearest entry");
301        assert_eq!(buf.content[0].bg, Color::Indexed(bottom.to_ansi256()));
302        let mut buf = painted("a");
303        reduce(&mut buf, ColorDepth::Ansi256, Rgb::new(12, 12, 14));
304        assert_ne!(buf.content[0].fg, Color::Indexed(top.to_ansi256()), "a letter is still kept readable");
305    }
306
307    #[test]
308    fn resolves_theme_properties() {
309        let (theme, _) = ThemeRegistry::builtin().resolve_or_default("monochrome");
310        let style = WidgetStyle::new(theme.style("button", Some("primary"), &[]), 0.0);
311        // Primary rests on a tint of the accent, never the full fill, so hover and press can rise.
312        assert_ne!(style.text().bg, theme.color("accent"));
313        assert_eq!(style.text().fg, theme.color("accent"));
314        assert!(style.text().bold);
315        assert_eq!(style.padding(), Padding::symmetric(0, 2));
316    }
317}