Skip to main content

rig_tui/
theme.rs

1//! The view's colors, by what each thing is: a band behind what the user
2//! wrote, one behind tool calls and what plugins put in the conversation,
3//! the model's replies on the terminal's own background, red and green
4//! behind a diff's removed and added lines, stronger behind the words that
5//! changed, a tool call's row tinted by where it stands, and the surfaces
6//! of panels and of boxes over the screen. Each
7//! is mixed from the terminal's background, asked of the terminal once it
8//! is opened, so it reads as a tint on a dark or a light terminal alike; a
9//! terminal without 24-bit color gets fixed shades of its 256.
10//!
11//! Nothing is drawn with box-drawing lines: a surface is a flat tint, the
12//! higher the lighter (on a dark terminal), with a bold title on its first
13//! row and a cell of padding around its content.
14//!
15//! With `NO_COLOR` set the theme is [`plain`](Theme::plain): the view draws
16//! no color at all, and what a color said is said with bold, underline and
17//! reverse instead, through [`Theme::selection`], [`Theme::changed_word`]
18//! and a surface's reversed title.
19
20use bevy_ecs::prelude::*;
21use ratatui::style::{Color, Modifier, Style, Stylize};
22use ratatui::text::{Line, Span};
23use ratatui::widgets::{Block, Padding};
24
25/// The background assumed when the terminal does not say its own, and a
26/// light one's.
27const DARK: Rgb = (0x1c, 0x1c, 0x1c);
28const LIGHT: Rgb = (0xfa, 0xfa, 0xfa);
29
30/// The colors each band leans to, and how far.
31const ADDED: (Rgb, f32) = ((0x3f, 0xb9, 0x50), 0.16);
32const ADDED_WORD: (Rgb, f32) = ((0x3f, 0xb9, 0x50), 0.38);
33const REMOVED: (Rgb, f32) = ((0xf8, 0x51, 0x49), 0.16);
34const REMOVED_WORD: (Rgb, f32) = ((0xf8, 0x51, 0x49), 0.38);
35const SELECTED: (Rgb, f32) = ((0xd2, 0x99, 0x22), 0.30);
36const CALL_RUNNING: (Rgb, f32) = ((0xd2, 0x99, 0x22), 0.14);
37const CALL_DONE: (Rgb, f32) = ((0x3f, 0xb9, 0x50), 0.10);
38const CALL_FAILED: (Rgb, f32) = ((0xf8, 0x51, 0x49), 0.24);
39const NOTE: (Rgb, f32) = ((0x58, 0xa6, 0xff), 0.16);
40const IMPORTANT: (Rgb, f32) = ((0xa3, 0x71, 0xf7), 0.18);
41/// How far the user's and the tool band lean from the background to the
42/// foreground: gray, the user's the lighter.
43const USER: f32 = 0.12;
44const TOOL: f32 = 0.06;
45/// How far a panel's, the input's and a box over the screen's surface lean
46/// the same way: a panel just off the background, the input a step above
47/// what the user sent, a box above everything else.
48const PANEL: f32 = 0.04;
49const INPUT: f32 = 0.17;
50const RAISED: f32 = 0.21;
51/// How far a call not yet running leans to the foreground: a step above
52/// the tool band its output is on.
53const CALL_WAITING: f32 = 0.10;
54/// How far code leans to the foreground: just off the background.
55const CODE: f32 = 0.08;
56
57/// The bands of a terminal without 24-bit color, of its 256: on a dark
58/// background and on a light one. A tint mixed as above would fall on
59/// the same few grays.
60const DARK_256: Theme = indexed(
61    DARK,
62    [
63        238, 236, 22, 28, 52, 88, 58, 235, 240, 237, 94, 23, 89, 239, 234, 17, 53,
64    ],
65);
66const LIGHT_256: Theme = indexed(
67    LIGHT,
68    [
69        252, 254, 194, 157, 224, 217, 230, 255, 248, 251, 223, 193, 218, 250, 253, 189, 225,
70    ],
71);
72
73type Rgb = (u8, u8, u8);
74
75/// The view's colors, a resource. Plugins' looks and panels may use them
76/// too, such as [`added`](Self::added) behind a line a tool added, or
77/// [`panel`](Self::panel) for a panel's surface.
78#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq, Hash)]
79pub struct Theme {
80    /// Behind what the user wrote.
81    pub user: Color,
82    /// Behind tool calls, their results, and what plugins put in the
83    /// conversation, such as notes and a compaction's summary.
84    pub tool: Color,
85    /// Behind a diff's added lines.
86    pub added: Color,
87    /// Behind the words an added line changed.
88    pub added_word: Color,
89    /// Behind a diff's removed lines.
90    pub removed: Color,
91    /// Behind the words a removed line changed.
92    pub removed_word: Color,
93    /// Behind the item the user selected, in the transcript and in lists.
94    pub selected: Color,
95    /// The surface of a panel beside the transcript.
96    pub panel_surface: Color,
97    /// The surface of a box over the screen, such as a picker.
98    pub raised_surface: Color,
99    /// Behind the input, a step off what the user sent.
100    pub input: Color,
101    /// Behind a tool call's row while it streams, waits or is held.
102    pub call_waiting: Color,
103    /// Behind a running call's row, and a warning in a reply.
104    pub call_running: Color,
105    /// Behind an answered call's row, and a tip in a reply.
106    pub call_done: Color,
107    /// Behind the row of a call that failed, and a caution in a reply.
108    pub call_failed: Color,
109    /// Behind code in a reply: a fenced block, and inline code.
110    pub code: Color,
111    /// Behind a note in a reply (`> [!NOTE]`). A tip uses
112    /// [`call_done`](Self::call_done), a warning
113    /// [`call_running`](Self::call_running), a caution
114    /// [`call_failed`](Self::call_failed).
115    pub note: Color,
116    /// Behind something important in a reply (`> [!IMPORTANT]`).
117    pub important: Color,
118    /// No colors: `NO_COLOR` is set. The view strips every color it would
119    /// draw, and looks mark with bold, underline and reverse instead.
120    pub plain: bool,
121    /// The terminal's background, which [`shade`](Self::shade) leans from.
122    background: Rgb,
123    /// Whether the terminal shows 24-bit color.
124    truecolor: bool,
125}
126
127impl Default for Theme {
128    /// The colors for a dark terminal, in 24-bit color when `COLORTERM`
129    /// says the terminal has it.
130    fn default() -> Self {
131        Self::on(DARK, truecolor())
132    }
133}
134
135impl Theme {
136    /// The colors on a terminal whose background is `background`, in
137    /// 24-bit color or the nearest of 256.
138    pub fn on(background: Rgb, truecolor: bool) -> Self {
139        let dark = lightness(background) < 0.5;
140        if !truecolor {
141            return if dark { DARK_256 } else { LIGHT_256 };
142        }
143        let contrast = if dark { (0xff, 0xff, 0xff) } else { (0, 0, 0) };
144        let lean = |(to, by): (Rgb, f32)| {
145            let (r, g, b) = mix(background, to, by);
146            Color::Rgb(r, g, b)
147        };
148        Self {
149            user: lean((contrast, USER)),
150            tool: lean((contrast, TOOL)),
151            added: lean(ADDED),
152            added_word: lean(ADDED_WORD),
153            removed: lean(REMOVED),
154            removed_word: lean(REMOVED_WORD),
155            selected: lean(SELECTED),
156            panel_surface: lean((contrast, PANEL)),
157            raised_surface: lean((contrast, RAISED)),
158            input: lean((contrast, INPUT)),
159            call_waiting: lean((contrast, CALL_WAITING)),
160            call_running: lean(CALL_RUNNING),
161            call_done: lean(CALL_DONE),
162            call_failed: lean(CALL_FAILED),
163            code: lean((contrast, CODE)),
164            note: lean(NOTE),
165            important: lean(IMPORTANT),
166            plain: false,
167            background,
168            truecolor,
169        }
170    }
171
172    /// A gray `by` (0 to 1) of the way from the terminal's background to
173    /// the far end of its lightness: white on a dark terminal, black on a
174    /// light one; the nearest of 256's grays without 24-bit color.
175    pub fn shade(&self, by: f32) -> Color {
176        let toward = match lightness(self.background) < 0.5 {
177            true => (0xff, 0xff, 0xff),
178            false => (0, 0, 0),
179        };
180        let (r, g, b) = mix(self.background, toward, by.clamp(0.0, 1.0));
181        if self.truecolor {
182            return Color::Rgb(r, g, b);
183        }
184        // The 256's grays are 232 (8) to 255 (238), ten apart.
185        let level = lightness((r, g, b)) * 255.0;
186        let step = ((level - 8.0) / 10.0).round().clamp(0.0, 23.0) as u8;
187        Color::Indexed(232 + step)
188    }
189
190    /// The same theme with no colors, as with `NO_COLOR`.
191    pub fn without_color(self) -> Self {
192        Self {
193            plain: true,
194            ..self
195        }
196    }
197
198    /// The look of a selected item: the [`selected`](Self::selected) tint,
199    /// or reverse video when [`plain`](Self::plain).
200    pub fn selection(&self) -> Style {
201        match self.plain {
202            true => Style::new().add_modifier(Modifier::REVERSED),
203            false => Style::new().bg(self.selected),
204        }
205    }
206
207    /// The look of a word a diff's line changed, on `tint` (such as
208    /// [`added_word`](Self::added_word)), or underlined when
209    /// [`plain`](Self::plain).
210    pub fn changed_word(&self, tint: Color) -> Style {
211        match self.plain {
212            true => Style::new().add_modifier(Modifier::UNDERLINED),
213            false => Style::new().bg(tint),
214        }
215    }
216
217    /// A panel's surface titled `title`: a flat
218    /// [`panel_surface`](Self::panel_surface) with the title in bold on its
219    /// first row, and a cell of padding left, right and below. Its
220    /// [`inner`](Block::inner) area is the one a bordered block leaves.
221    pub fn panel<'a>(&self, title: impl Into<Line<'a>>) -> Block<'a> {
222        surface(self.panel_surface, title.into(), self.plain)
223    }
224
225    /// A box over the screen titled `title`, as [`panel`](Self::panel) but
226    /// on the [`raised_surface`](Self::raised_surface).
227    pub fn raised<'a>(&self, title: impl Into<Line<'a>>) -> Block<'a> {
228        surface(self.raised_surface, title.into(), self.plain)
229    }
230
231    /// The colors on the terminal the view runs in, asking it for its
232    /// background; the dark ones when it does not answer, and none with
233    /// `NO_COLOR` set.
234    pub(crate) fn of_terminal() -> Self {
235        if no_color() {
236            return Self::default().without_color();
237        }
238        let options = terminal_colorsaurus::QueryOptions::default();
239        match terminal_colorsaurus::background_color(options) {
240            Ok(background) => Self::on(background.scale_to_8bit(), truecolor()),
241            Err(_) => Self::default(),
242        }
243    }
244}
245
246/// A flat surface of `color` titled `title`, padded to leave what a
247/// border would; with no colors, its title reversed marks its top.
248fn surface(color: Color, mut title: Line<'_>, plain: bool) -> Block<'_> {
249    title.spans.insert(0, Span::from(" "));
250    title.spans.push(Span::from(" "));
251    let title = match plain {
252        true => title.bold().reversed(),
253        false => title.bold(),
254    };
255    Block::new()
256        .bg(color)
257        .title(title)
258        .padding(Padding::new(1, 1, 0, 1))
259}
260
261/// Whether `NO_COLOR` asks for no colors: set and not empty.
262fn no_color() -> bool {
263    std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty())
264}
265
266/// Whether `COLORTERM` says the terminal shows 24-bit color.
267fn truecolor() -> bool {
268    std::env::var("COLORTERM").is_ok_and(|term| term == "truecolor" || term == "24bit")
269}
270
271/// `from` moved `by` (0 to 1) of the way to `to`.
272fn mix(from: Rgb, to: Rgb, by: f32) -> Rgb {
273    let channel = |from: u8, to: u8| {
274        let mixed = f32::from(from) + (f32::from(to) - f32::from(from)) * by;
275        mixed.round().clamp(0.0, 255.0) as u8
276    };
277    (
278        channel(from.0, to.0),
279        channel(from.1, to.1),
280        channel(from.2, to.2),
281    )
282}
283
284/// How light `rgb` looks, from 0 to 1.
285fn lightness((r, g, b): Rgb) -> f32 {
286    (0.2126 * f32::from(r) + 0.7152 * f32::from(g) + 0.0722 * f32::from(b)) / 255.0
287}
288
289/// The bands of 256-color indices, in the order of [`Theme`]'s fields.
290const fn indexed(
291    background: Rgb,
292    [
293        user,
294        tool,
295        added,
296        added_word,
297        removed,
298        removed_word,
299        selected,
300        panel_surface,
301        raised_surface,
302        call_waiting,
303        call_running,
304        call_done,
305        call_failed,
306        input,
307        code,
308        note,
309        important,
310    ]: [u8; 17],
311) -> Theme {
312    Theme {
313        user: Color::Indexed(user),
314        tool: Color::Indexed(tool),
315        added: Color::Indexed(added),
316        added_word: Color::Indexed(added_word),
317        removed: Color::Indexed(removed),
318        removed_word: Color::Indexed(removed_word),
319        selected: Color::Indexed(selected),
320        panel_surface: Color::Indexed(panel_surface),
321        raised_surface: Color::Indexed(raised_surface),
322        call_waiting: Color::Indexed(call_waiting),
323        call_running: Color::Indexed(call_running),
324        call_done: Color::Indexed(call_done),
325        call_failed: Color::Indexed(call_failed),
326        input: Color::Indexed(input),
327        code: Color::Indexed(code),
328        note: Color::Indexed(note),
329        important: Color::Indexed(important),
330        plain: false,
331        background,
332        truecolor: false,
333    }
334}
335
336#[cfg(test)]
337mod tests;