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}