Skip to main content

rlx_core/render/
theme.rs

1//! The interface's look, declared once (ADR-0252): colour roles, a type scale,
2//! spacing, a corner radius, and motion timings with one easing curve.
3//!
4//! Every engine-drawn surface — the standalone's overlays, the core's
5//! now-playing banner and diagnostics panel, and so the foobar component's
6//! panel too — reads its values from [`THEME`]. The studio's `tokens.css` is
7//! generated from the same table by [`css`], and `core/tests/suite/ui_tokens.rs`
8//! fails when the committed file differs from what this module renders.
9//!
10//! The table names **roles**, never uses: `accent`, not `browser_row_selected`.
11//! A surface restyle is a change of which role a surface reads.
12//!
13//! ## Colour encoding
14//!
15//! A [`Color`] holds **sRGB-encoded** components in `0.0..=1.0`, the numbers a
16//! hex literal spells. That is what the text layer takes (glyphon treats its
17//! colours as sRGB and linearises them for an sRGB target) and what CSS takes.
18//! A pass that writes a colour straight out of a shader onto the `*Srgb`
19//! surface — the diagnostics panel's quads — must hand the shader
20//! [`Color::linear`] instead, or every value lands lighter than declared.
21//!
22//! Pure data, no platform or windowing type: this module is part of the
23//! source-agnostic core and compiles in every build, with or without `text`.
24
25// Hot-path panic-denial pragma (`render/` scan set): the easing curve is
26// evaluated every frame an overlay moves.
27#![deny(
28    clippy::unwrap_used,
29    clippy::expect_used,
30    clippy::indexing_slicing,
31    clippy::panic,
32    clippy::unreachable
33)]
34
35use std::fmt::Write as _;
36
37/// One colour role: sRGB-encoded RGBA, each component in `0.0..=1.0`.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct Color {
40    /// Red, sRGB-encoded.
41    pub r: f32,
42    /// Green, sRGB-encoded.
43    pub g: f32,
44    /// Blue, sRGB-encoded.
45    pub b: f32,
46    /// Opacity, which no encoding touches.
47    pub a: f32,
48}
49
50impl Color {
51    /// An opaque colour from a `0xRRGGBB` literal.
52    pub const fn hex(rgb: u32) -> Self {
53        Self {
54            r: ((rgb >> 16) & 0xff) as f32 / 255.0,
55            g: ((rgb >> 8) & 0xff) as f32 / 255.0,
56            b: (rgb & 0xff) as f32 / 255.0,
57            a: 1.0,
58        }
59    }
60
61    /// The same colour at opacity `a`.
62    pub const fn alpha(self, a: f32) -> Self {
63        Self { a, ..self }
64    }
65
66    /// `[r, g, b, a]`, sRGB-encoded — what a text run and a `Line` take.
67    pub const fn rgba(self) -> [f32; 4] {
68        [self.r, self.g, self.b, self.a]
69    }
70
71    /// `[r, g, b]`, sRGB-encoded, for a caller that applies its own alpha.
72    pub const fn rgb(self) -> [f32; 3] {
73        [self.r, self.g, self.b]
74    }
75
76    /// `[r, g, b, a]` with the colour channels linearised and alpha untouched —
77    /// what a shader writing to an `*Srgb` target must output to show this
78    /// colour as declared.
79    pub fn linear(self) -> [f32; 4] {
80        [
81            srgb_to_linear(self.r),
82            srgb_to_linear(self.g),
83            srgb_to_linear(self.b),
84            self.a,
85        ]
86    }
87
88    /// The CSS spelling: `#rrggbb` when opaque, `#rrggbbaa` otherwise.
89    pub fn css(self) -> String {
90        let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
91        let mut out = format!(
92            "#{:02x}{:02x}{:02x}",
93            byte(self.r),
94            byte(self.g),
95            byte(self.b)
96        );
97        if self.a < 1.0 {
98            let _ = write!(out, "{:02x}", byte(self.a));
99        }
100        out
101    }
102}
103
104/// The IEC 61966-2-1 sRGB decode of one channel.
105fn srgb_to_linear(c: f32) -> f32 {
106    if c <= 0.040_45 {
107        c / 12.92
108    } else {
109        ((c + 0.055) / 1.055).powf(2.4)
110    }
111}
112
113/// The one easing curve every surface moves on, as CSS's `cubic-bezier(x1, y1,
114/// x2, y2)` with the endpoints pinned at `(0, 0)` and `(1, 1)`.
115///
116/// **Both `y` control points must stay inside `0.0..=1.0`.** That is what keeps
117/// the curve monotone — an envelope that overshoots and comes back would make a
118/// panel's alpha rise past its target and fall again. Both `x` control points
119/// must stay inside `0.0..=1.0` too, which is what makes `x(t)` invertible.
120#[derive(Clone, Copy, Debug, PartialEq)]
121pub struct Ease {
122    /// First control point, time axis.
123    pub x1: f32,
124    /// First control point, progress axis.
125    pub y1: f32,
126    /// Second control point, time axis.
127    pub x2: f32,
128    /// Second control point, progress axis.
129    pub y2: f32,
130}
131
132impl Ease {
133    /// Progress at normalised time `t`: `0.0` at `t <= 0`, `1.0` at `t >= 1`, and
134    /// the curve's height in between.
135    ///
136    /// Solves `x(s) = t` by bisection on the curve parameter `s` — a fixed 40
137    /// halvings in `f64`, which puts `s` well inside an `f32` step of the root
138    /// and allocates nothing.
139    ///
140    /// **Monotone in `t` to the last bit, by construction.** The height is read
141    /// at the bisection's lower bound, which never decreases as `t` grows (every
142    /// comparison `t` passes, a larger `t` passes too), and it is computed in
143    /// `f64` and rounded once — an `f32` evaluation of the height wobbles by an
144    /// ulp near the top of a flat ease-out, which is a panel's alpha stepping
145    /// back down on its last frames.
146    pub fn at(self, t: f32) -> f32 {
147        if !t.is_finite() || t <= 0.0 {
148            return 0.0;
149        }
150        if t >= 1.0 {
151            return 1.0;
152        }
153        let t = f64::from(t);
154        let (x1, x2) = (f64::from(self.x1), f64::from(self.x2));
155        let (mut lo, mut hi) = (0.0_f64, 1.0_f64);
156        for _ in 0..40 {
157            let mid = 0.5 * (lo + hi);
158            if bezier(x1, x2, mid) < t {
159                lo = mid;
160            } else {
161                hi = mid;
162            }
163        }
164        let y = bezier(f64::from(self.y1), f64::from(self.y2), lo);
165        (y as f32).clamp(0.0, 1.0)
166    }
167
168    /// The CSS spelling, `cubic-bezier(x1, y1, x2, y2)`.
169    pub fn css(self) -> String {
170        format!(
171            "cubic-bezier({}, {}, {}, {})",
172            self.x1, self.y1, self.x2, self.y2
173        )
174    }
175}
176
177/// One coordinate of a cubic Bézier from `0` to `1` with control values `p1`
178/// and `p2`, at parameter `s`.
179fn bezier(p1: f64, p2: f64, s: f64) -> f64 {
180    let u = 1.0 - s;
181    3.0 * u * u * s * p1 + 3.0 * u * s * s * p2 + s * s * s
182}
183
184/// The whole look. One instance, [`THEME`]; a field is a role, a step, or a
185/// timing, and never names the surface that reads it.
186#[derive(Clone, Copy, Debug, PartialEq)]
187pub struct Theme {
188    /// The studio's window background. No engine surface paints it: the scene is
189    /// the standalone's background.
190    pub bg: Color,
191    /// A panel's fill, translucent so the scene reads through it.
192    pub panel: Color,
193    /// A panel's 1 px lit edge.
194    pub panel_edge: Color,
195    /// The scanline modulation a panel carries on every [`Self::scanline_pitch`]th line.
196    pub scanline: Color,
197    /// Primary text.
198    pub text: Color,
199    /// Secondary text: headers, captions, status lines.
200    pub text_dim: Color,
201    /// Tertiary text: a placeholder, a control that is off.
202    pub text_faint: Color,
203    /// The warm accent: what the keys act on.
204    pub accent: Color,
205    /// The fill behind a highlighted row.
206    pub highlight: Color,
207    /// A marked-as-favourite item, warm but quieter than the accent.
208    pub favourite: Color,
209    /// A healthy reading.
210    pub good: Color,
211    /// A cool informational fill: a meter's level.
212    pub info: Color,
213    /// A reading near its limit.
214    pub warn: Color,
215    /// A reading past its limit, or a fault.
216    pub error: Color,
217    /// A meter's empty track.
218    pub trough: Color,
219    /// Font sizes in px at a 1080-line target, smallest first. Declared for the
220    /// studio's generated tokens; no engine surface reads them yet, and the
221    /// overlays still carry their own fixed sizes. A surface that adopts them
222    /// scales by `target height / 1080`, never by an internal grid (ADR-0037).
223    pub type_scale: [f32; 5],
224    /// Spacing steps in px at the same 1080-line reference, smallest first.
225    pub space: [f32; 5],
226    /// Corner radius of a panel, px at the reference.
227    pub radius: f32,
228    /// Rows between scanlines on a panel.
229    pub scanline_pitch: u32,
230    /// Every panel's open and close, and every short state change.
231    pub motion_short_ms: u32,
232    /// A change that moves a whole view.
233    pub motion_long_ms: u32,
234    /// The one easing curve.
235    pub ease: Ease,
236}
237
238/// The reference target height [`Theme::type_scale`] and [`Theme::space`] are
239/// declared at.
240pub const REFERENCE_HEIGHT: f32 = 1080.0;
241
242const ACCENT: Color = Color::hex(0xffb454);
243
244/// The look: direction A, "Amber phosphor" (Plan 0231 Phase 3) — a warm amber
245/// accent over cool dark neutrals, panels with a lit edge and a faint scanline,
246/// and one ease-out curve with a long settle.
247pub const THEME: Theme = Theme {
248    bg: Color::hex(0x0b0d11),
249    panel: Color::hex(0x11141a).alpha(0.92),
250    panel_edge: ACCENT.alpha(0.43),
251    scanline: Color::hex(0x000000).alpha(0.06),
252    text: Color::hex(0xece8e1),
253    text_dim: Color::hex(0xa7acb5),
254    text_faint: Color::hex(0x6b717c),
255    accent: ACCENT,
256    highlight: ACCENT.alpha(0.13),
257    favourite: Color::hex(0xe8c9a0),
258    good: Color::hex(0x5fd38d),
259    info: Color::hex(0x6cb6ff),
260    warn: Color::hex(0xf0b429),
261    error: Color::hex(0xf2686c),
262    trough: Color::hex(0x262a33),
263    type_scale: [14.0, 18.0, 22.0, 28.0, 32.0],
264    space: [4.0, 8.0, 16.0, 24.0, 32.0],
265    radius: 6.0,
266    scanline_pitch: 4,
267    motion_short_ms: 180,
268    motion_long_ms: 320,
269    ease: Ease {
270        x1: 0.16,
271        y1: 1.0,
272        x2: 0.3,
273        y2: 1.0,
274    },
275};
276
277/// The studio's `tokens.css`, rendered from [`THEME`].
278///
279/// Deterministic and newline-terminated. The custom property names are the
280/// studio's own (`--bg`, `--panel`, `--text-dim`, ...), so its stylesheets read a
281/// role by the same name the table gives it.
282pub fn css() -> String {
283    let t = THEME;
284    let mut out = String::new();
285    out.push_str(
286        "/* GENERATED from core/src/render/theme.rs by core/tests/suite/ui_tokens.rs.\n   \
287         Do not edit: regenerate with RLX_UPDATE_UI_TOKENS=1 (docs/developing.md). */\n",
288    );
289    out.push_str(":root {\n");
290    let colors: [(&str, Color); 15] = [
291        ("bg", t.bg),
292        ("panel", t.panel),
293        ("panel-edge", t.panel_edge),
294        ("scanline", t.scanline),
295        ("text", t.text),
296        ("text-dim", t.text_dim),
297        ("text-faint", t.text_faint),
298        ("accent", t.accent),
299        ("highlight", t.highlight),
300        ("favourite", t.favourite),
301        ("good", t.good),
302        ("info", t.info),
303        ("warn", t.warn),
304        ("error", t.error),
305        ("trough", t.trough),
306    ];
307    for (name, color) in colors {
308        let _ = writeln!(out, "  --{name}: {};", color.css());
309    }
310    for (i, px) in t.type_scale.iter().enumerate() {
311        let _ = writeln!(out, "  --type-{i}: {px}px;");
312    }
313    for (i, px) in t.space.iter().enumerate() {
314        let _ = writeln!(out, "  --space-{i}: {px}px;");
315    }
316    let _ = writeln!(out, "  --radius: {}px;", t.radius);
317    let _ = writeln!(out, "  --scanline-pitch: {}px;", t.scanline_pitch);
318    let _ = writeln!(out, "  --motion-short: {}ms;", t.motion_short_ms);
319    let _ = writeln!(out, "  --motion-long: {}ms;", t.motion_long_ms);
320    let _ = writeln!(out, "  --ease: {};", t.ease.css());
321    out.push_str("}\n");
322    out
323}
324
325#[cfg(test)]
326mod tests {
327    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
328
329    use super::*;
330
331    #[test]
332    fn a_hex_literal_round_trips_through_css() {
333        assert_eq!(Color::hex(0xffb454).css(), "#ffb454");
334        assert_eq!(Color::hex(0x11141a).alpha(0.92).css(), "#11141aeb");
335    }
336
337    #[test]
338    fn the_ease_is_pinned_at_both_ends_and_monotone_between() {
339        let ease = THEME.ease;
340        assert_eq!(ease.at(0.0), 0.0);
341        assert_eq!(ease.at(1.0), 1.0);
342        let mut prev = 0.0;
343        for i in 1..=200 {
344            let v = ease.at(i as f32 / 200.0);
345            assert!(v >= prev, "the ease fell at step {i}: {prev} -> {v}");
346            assert!(v <= 1.0, "the ease overshot at step {i}: {v}");
347            prev = v;
348        }
349    }
350
351    #[test]
352    fn the_ease_is_an_ease_out() {
353        // Past halfway in value by a quarter of the time: fast out, long settle.
354        assert!(THEME.ease.at(0.25) > 0.5);
355    }
356
357    #[test]
358    fn linear_decodes_srgb_and_leaves_alpha_alone() {
359        let [r, g, b, a] = Color::hex(0xffffff).alpha(0.5).linear();
360        assert!((r - 1.0).abs() < 1e-6 && (g - 1.0).abs() < 1e-6 && (b - 1.0).abs() < 1e-6);
361        assert_eq!(a, 0.5);
362        // Mid-grey encodes well above its linear value.
363        assert!(Color::hex(0x808080).linear()[0] < 0.25);
364    }
365}