Skip to main content

repame_ui/
palette.rs

1//! Palette tokens and the chip/button shapes the per-game palettes redraw.
2//!
3//! Eleven template-line games define their own colour constants and eight to
4//! ten of them re-derive the same blending and the same two shapes. Two of them
5//! (`kickback` and `rimshot`) are byte-identical, so [`Tokens`] takes that pair
6//! as its default: warm ink, cream chips, a gold accent, and the six touch
7//! colours the virtual stick draws with.
8//!
9//! Tokens are named by role, not by hue, so a game re-skins the kit by writing
10//! a different [`Tokens`] value (`Kit::with_tokens`) without touching a control.
11//! Art colours — sand, water, the player's teal — stay in the game: they are
12//! game content, not chrome.
13//!
14//! [`mix`] and [`lighten`] are the two blending functions eleven copies share.
15//! [`hand_cut`] is the optional irregular corner radii the identical pair uses
16//! so a row of chips does not read as a row of copies.
17
18use repose_core::prelude::{Color, Modifier, StateColors};
19use repose_core::{AlignItems, Dp, JustifyContent, PaddingValues};
20
21/// The font the template line's chrome is drawn in. The game registers the
22/// face; the kit only names it.
23pub const FONT: &str = "Fredoka";
24
25/// Font family for glyph buttons (Material Symbols Outlined).
26pub const MATERIAL_SYMBOLS: &str = "Material Symbols Outlined";
27
28/// Side of a square glyph button in design units.
29pub const ICON_BUTTON: f32 = 46.0;
30
31/// Glyph size inside [`ICON_BUTTON`], in design units.
32pub const ICON_GLYPH: f32 = 26.0;
33
34/// Named colours for every kit control.
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct Tokens {
37    /// Outlines, text fill and hard shadows: the darkest chrome colour.
38    pub ink: Color,
39    /// Card and button face.
40    pub cream: Color,
41    /// Primary accent (win, revive, the transition disc).
42    pub gold: Color,
43    /// The accent's shaded half.
44    pub gold_dark: Color,
45    /// Attention accent (danger, counter).
46    pub coral: Color,
47    /// Background sky at the top of the frame.
48    pub sky: Color,
49    /// Background sky at the horizon.
50    pub sky_dark: Color,
51    /// Secondary accent.
52    pub cyan: Color,
53    /// Secondary accent, hot.
54    pub pink: Color,
55    /// Positive/success accent.
56    pub green: Color,
57    /// Full-screen dim behind a modal panel.
58    pub scrim: Color,
59    /// The hard button shadow.
60    pub button_shadow: Color,
61    /// Virtual stick base fill.
62    pub touch_bg: Color,
63    /// Virtual stick ring.
64    pub touch_ring: Color,
65    /// Virtual stick base shadow.
66    pub touch_shadow: Color,
67    /// Stick knob fill.
68    pub touch_knob: Color,
69    /// Stick knob edge.
70    pub touch_knob_dark: Color,
71    /// Stick centre pip.
72    pub touch_center: Color,
73}
74
75impl Tokens {
76    /// The byte-identical `kickback`/`rimshot` palette.
77    pub const KICKBACK: Tokens = Tokens {
78        ink: Color(0x2B, 0x1A, 0x14, 0xFF),
79        cream: Color(0xFF, 0xF3, 0xDC, 0xFF),
80        gold: Color(0xFF, 0xE0, 0x66, 0xFF),
81        gold_dark: Color(0xD9, 0xA5, 0x1E, 0xFF),
82        coral: Color(0xFF, 0x5F, 0xA2, 0xFF),
83        sky: Color(0xF2, 0xA6, 0x5A, 0xFF),
84        sky_dark: Color(0xE8, 0x89, 0x4A, 0xFF),
85        cyan: Color(0x7F, 0xF0, 0xE4, 0xFF),
86        pink: Color(0xFF, 0x8A, 0xC8, 0xFF),
87        green: Color(0x8B, 0xD9, 0x6E, 0xFF),
88        scrim: Color(0x2B, 0x1A, 0x14, 0x77),
89        button_shadow: Color(0x2B, 0x1A, 0x14, 0x40),
90        touch_bg: Color(0xFF, 0xF3, 0xDC, 0x99),
91        touch_ring: Color(0x2B, 0x1A, 0x14, 0xFF),
92        touch_shadow: Color(0x2B, 0x1A, 0x14, 0x2E),
93        touch_knob: Color(0xFF, 0xFF, 0xFF, 0xFF),
94        touch_knob_dark: Color(0xE8, 0xC4, 0x9E, 0xFF),
95        touch_center: Color(0x2B, 0x1A, 0x14, 0xFF),
96    };
97
98    /// The cool sky/cream palette of the plain template, for a game that wants
99    /// the kit's chrome without the sunset shift.
100    pub const TEMPLATE: Tokens = Tokens {
101        ink: Color(0x1E, 0x2A, 0x4A, 0xFF),
102        cream: Color(0xFF, 0xF8, 0xEA, 0xFF),
103        gold: Color(0xFF, 0xC9, 0x3C, 0xFF),
104        gold_dark: Color(0xE0, 0x9B, 0x1F, 0xFF),
105        coral: Color(0xF4, 0x71, 0x6A, 0xFF),
106        sky: Color(0x7F, 0xC9, 0xF2, 0xFF),
107        sky_dark: Color(0x5F, 0xAC, 0xDE, 0xFF),
108        cyan: Color(0x46, 0xC8, 0xF5, 0xFF),
109        pink: Color(0xFF, 0x9E, 0xB1, 0xFF),
110        green: Color(0x7B, 0xD8, 0x8F, 0xFF),
111        scrim: Color(0x1E, 0x2A, 0x4A, 0x66),
112        button_shadow: Color(0x1E, 0x2A, 0x4A, 0x33),
113        touch_bg: Color(0xFF, 0xF8, 0xEA, 0x99),
114        touch_ring: Color(0x1E, 0x2A, 0x4A, 0xFF),
115        touch_shadow: Color(0x1E, 0x2A, 0x4A, 0x22),
116        touch_knob: Color(0xFF, 0xFF, 0xFF, 0xFF),
117        touch_knob_dark: Color(0xC9, 0xD8, 0xEE, 0xFF),
118        touch_center: Color(0x1E, 0x2A, 0x4A, 0xFF),
119    };
120}
121
122impl Default for Tokens {
123    fn default() -> Self {
124        Self::KICKBACK
125    }
126}
127
128/// Linear blend between two colours. `t` is clamped to `0..=1`, so `0` is `a`
129/// and `1` is `b`.
130pub fn mix(a: Color, b: Color, t: f32) -> Color {
131    let t = t.clamp(0.0, 1.0);
132    let blend = |from: u8, to: u8| -> u8 {
133        (from as f32 + (to as f32 - from as f32) * t) as u8
134    };
135    Color(
136        blend(a.0, b.0),
137        blend(a.1, b.1),
138        blend(a.2, b.2),
139        blend(a.3, b.3),
140    )
141}
142
143/// Blend a colour toward white.
144pub fn lighten(color: Color, t: f32) -> Color {
145    mix(color, Color(0xFF, 0xFF, 0xFF, 0xFF), t)
146}
147
148/// Blend a colour toward black.
149pub fn darken(color: Color, t: f32) -> Color {
150    mix(color, Color(0x00, 0x00, 0x00, 0xFF), t)
151}
152
153/// The five interaction tints a face colour needs.
154///
155/// Repose resolves a control's fill from these, which is why one helper is
156/// enough for every button in the kit.
157pub fn state_colors(face: Color, ink: Color) -> StateColors {
158    StateColors {
159        default: face,
160        hovered: lighten(face, 0.08),
161        focused: lighten(face, 0.04),
162        pressed: mix(face, ink, 0.16),
163        disabled: mix(face, ink, 0.30),
164        dragged: mix(face, ink, 0.16),
165    }
166}
167
168/// Irregular per-corner radii, pulled `±34%` around `radius` from `seed`.
169///
170/// The identical pair of games uses this so neighbouring chips do not share a
171/// silhouette. Keep a per-control seed stable (a counter over the panel, or a
172/// hash of the label) or the corners will jitter every frame.
173pub fn hand_cut(radius: f32, seed: u32) -> [Dp; 4] {
174    let wobble = |k: u32| -> Dp {
175        let bits = seed.wrapping_mul(0x9E37_79B9 ^ k.wrapping_mul(0x85EB_CA6B));
176        let frac = ((bits >> 16) & 0xFF) as f32 / 255.0 - 0.5;
177        Dp(radius * (1.0 + frac * 0.34))
178    };
179    [wobble(1), wobble(2), wobble(3), wobble(4)]
180}
181
182/// The chunky game button: hard ink outline, hard shadow, centred content,
183/// focusable.
184///
185/// `radius` is the corner radius in design units. A caller that wants the
186/// irregular silhouette of [`hand_cut`] builds its own modifier and calls
187/// [`Modifier::border_radii`].
188pub fn chunky(
189    tokens: &Tokens,
190    face: Color,
191    pad_x: f32,
192    pad_y: f32,
193    radius: f32,
194    scale: f32,
195) -> Modifier {
196    Modifier::new()
197        .state_colors(state_colors(face, tokens.ink))
198        .border(Dp(3.0 * scale), tokens.ink, Dp(radius * scale))
199        .padding_values(PaddingValues {
200            left: Dp(pad_x * scale),
201            right: Dp(pad_x * scale),
202            top: Dp(pad_y * scale),
203            bottom: Dp(pad_y * scale),
204        })
205        .shadow_with_color(Dp(0.0), Dp(5.0 * scale), tokens.button_shadow)
206        .align_items(AlignItems::CENTER)
207        .justify_content(JustifyContent::CENTER)
208        .focusable(true)
209}
210
211/// The small rounded information chip (HUD counters, score, timer).
212pub fn chip(tokens: &Tokens, face: Color, scale: f32) -> Modifier {
213    Modifier::new()
214        .background(face)
215        .border(Dp(2.5 * scale), tokens.ink, Dp(14.0 * scale))
216        .padding_values(PaddingValues {
217            left: Dp(14.0 * scale),
218            right: Dp(14.0 * scale),
219            top: Dp(6.0 * scale),
220            bottom: Dp(6.0 * scale),
221        })
222}
223
224/// Text outline width in em-units for a `size`-point label: about a third of
225/// the size in design units, never thinner than 2.5.
226pub fn outline_for(size: f32) -> f32 {
227    (size / 3.0).max(2.5)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    const A: Color = Color(0x10, 0x20, 0x30, 0x40);
235    const B: Color = Color(0x80, 0x90, 0xA0, 0xFF);
236
237    #[test]
238    fn mix_endpoints_are_the_inputs() {
239        assert_eq!(mix(A, B, 0.0), A);
240        assert_eq!(mix(A, B, 1.0), B);
241        assert_eq!(mix(A, B, -3.0), A, "clamped below");
242        assert_eq!(mix(A, B, 9.0), B, "clamped above");
243    }
244
245    #[test]
246    fn mix_midpoint_is_the_average() {
247        assert_eq!(mix(A, B, 0.5), Color(0x48, 0x58, 0x68, 0x9F));
248    }
249
250    #[test]
251    fn lighten_and_darken_reach_white_and_black() {
252        assert_eq!(lighten(A, 0.0), A);
253        assert_eq!(lighten(A, 1.0), Color(0xFF, 0xFF, 0xFF, 0xFF));
254        assert_eq!(darken(A, 1.0), Color(0x00, 0x00, 0x00, 0xFF));
255    }
256
257    #[test]
258    fn state_colors_keep_the_face_as_default_and_shade_the_rest() {
259        let colors = state_colors(A, Color(0x00, 0x00, 0x00, 0xFF));
260        assert_eq!(colors.default, A);
261        assert_eq!(colors.hovered, lighten(A, 0.08));
262        assert_eq!(colors.focused, lighten(A, 0.04));
263        assert_eq!(colors.pressed, mix(A, Color(0x00, 0x00, 0x00, 0xFF), 0.16));
264        assert_eq!(colors.disabled, mix(A, Color(0x00, 0x00, 0x00, 0xFF), 0.30));
265    }
266
267    #[test]
268    fn hand_cut_stays_within_the_documented_band() {
269        for seed in [0u32, 1, 7, 0xDEAD_BEEF, 42] {
270            for corner in hand_cut(16.0, seed) {
271                assert!(
272                    (16.0 * 0.66..=16.0 * 1.34).contains(&corner.0),
273                    "seed {seed} produced {corner:?}"
274                );
275            }
276        }
277    }
278
279    #[test]
280    fn hand_cut_is_stable_for_a_seed() {
281        assert_eq!(hand_cut(12.0, 99), hand_cut(12.0, 99));
282        assert_ne!(hand_cut(12.0, 98), hand_cut(12.0, 99));
283    }
284
285    #[test]
286    fn the_default_tokens_are_the_palette_pair() {
287        assert_eq!(Tokens::default(), Tokens::KICKBACK);
288        assert_ne!(Tokens::KICKBACK.ink, Tokens::TEMPLATE.ink);
289    }
290}