Skip to main content

blockworx/canvas/
palette.rs

1use blockworx_paint::Color;
2use serde::{Deserialize, Serialize};
3
4use blockworx_geom::WorldPx;
5
6/// One of the 16 base colors of a [`Palette`]. `B00..B07` are the monochrome
7/// shades (darkest background to lightest foreground); `B08..B0F` are the
8/// accent colors. This is the base16 convention.
9///
10/// `Base` is the canvas's color vocabulary: drawing code names a [`Swatch`]
11/// (a `Base` slot plus an alpha), and the renderer resolves it against the
12/// active [`Palette`]. Higher layers map their own semantics (app "roles") onto
13/// `Base`, but the canvas itself only ever sees palette slots — never an
14/// arbitrary `Color`.
15#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, strum::EnumIter)]
16pub enum Base {
17    B00,
18    B01,
19    B02,
20    B03,
21    B04,
22    B05,
23    B06,
24    B07,
25    B08,
26    B09,
27    B0A,
28    B0B,
29    B0C,
30    B0D,
31    B0E,
32    B0F,
33}
34
35/// A palette color: one of the 16 [`Base`] slots, dimmed by an `alpha`
36/// multiplier. `base == None` resolves to a fully transparent color (the
37/// canvas's "draw nothing" color). This is the single color type at the
38/// [`Renderer`](crate::canvas::Renderer) interface — it keeps the API tied to
39/// the palette instead of letting arbitrary `Color`s proliferate. Construct a
40/// fully-opaque swatch from a `Base` via `Base.into()`; use [`Swatch::faded`]
41/// for an overlay tint and [`Swatch::TRANSPARENT`] for nothing.
42#[derive(Clone, Copy, PartialEq)]
43pub struct Swatch {
44    pub base: Option<Base>,
45    pub alpha: f32,
46}
47
48impl Swatch {
49    /// A fully transparent swatch (draws nothing).
50    pub const TRANSPARENT: Swatch = Swatch {
51        base: None,
52        alpha: 1.0,
53    };
54
55    /// A fully opaque swatch on `base`.
56    pub const fn solid(base: Base) -> Self {
57        Self {
58            base: Some(base),
59            alpha: 1.0,
60        }
61    }
62
63    /// `base` dimmed to `alpha` (0.0..=1.0), for overlay tints (hover, marquee,
64    /// faded routes, grid lines).
65    pub const fn faded(base: Base, alpha: f32) -> Self {
66        Self {
67            base: Some(base),
68            alpha,
69        }
70    }
71}
72
73impl From<Base> for Swatch {
74    fn from(base: Base) -> Self {
75        Swatch::solid(base)
76    }
77}
78
79/// A stroke described by a world-space width plus a [`Swatch`] — the
80/// palette-based counterpart to egui's `Stroke`. The renderer resolves the
81/// swatch to a color (and scales the width by zoom) at draw time. Construct from
82/// a `(width, Base)` or `(width, Swatch)` tuple, which wraps the literal width as
83/// a [`WorldPx`]; use [`PaletteStroke::NONE`] for no stroke.
84#[derive(Clone, Copy)]
85pub struct PaletteStroke {
86    pub width: WorldPx,
87    pub color: Swatch,
88}
89
90impl PaletteStroke {
91    /// A zero-width, transparent stroke (draws nothing).
92    pub const NONE: PaletteStroke = PaletteStroke {
93        width: WorldPx::ZERO,
94        color: Swatch::TRANSPARENT,
95    };
96}
97
98impl From<(f32, Base)> for PaletteStroke {
99    fn from((width, base): (f32, Base)) -> Self {
100        PaletteStroke {
101            width: WorldPx::new(width),
102            color: Swatch::solid(base),
103        }
104    }
105}
106
107impl From<(f32, Swatch)> for PaletteStroke {
108    fn from((width, color): (f32, Swatch)) -> Self {
109        PaletteStroke {
110            width: WorldPx::new(width),
111            color,
112        }
113    }
114}
115
116/// Which end of the light/dark axis a scheme sits on — the parameter form of
117/// `is_dark`, so a palette constructor reads `Luminance::Dark`, not `true`.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum Luminance {
120    Light,
121    Dark,
122}
123
124impl Luminance {
125    pub const fn is_dark(self) -> bool {
126        matches!(self, Luminance::Dark)
127    }
128}
129
130impl From<bool> for Luminance {
131    fn from(dark: bool) -> Self {
132        if dark {
133            Luminance::Dark
134        } else {
135            Luminance::Light
136        }
137    }
138}
139
140/// The 16 concrete colors a renderer draws from. Held by each
141/// [`Renderer`](crate::canvas::Renderer) backend so a [`Swatch`] → `Color`
142/// lookup can happen at draw time. Built by one of the named base16 scheme
143/// constructors; the active scheme is chosen by the user's preferences.
144#[derive(Clone)]
145pub struct Palette {
146    colors: [Color; 16],
147    /// Whether this is a dark scheme (`B00` darker than `B07`). Drives the egui
148    /// base preset in [`Palette::egui_visuals`]; ignored by the canvas renderers.
149    is_dark: bool,
150}
151
152impl Palette {
153    /// The concrete color for a base slot.
154    pub fn get(&self, base: Base) -> Color {
155        self.colors[base as usize]
156    }
157
158    /// Resolve a [`Swatch`] to a concrete color: its base color from the palette
159    /// dimmed by its alpha. A swatch with no base resolves to fully transparent.
160    pub fn resolve(&self, swatch: Swatch) -> Color {
161        match swatch.base {
162            Some(base) => self.get(base).gamma_multiply(swatch.alpha),
163            None => Color::TRANSPARENT,
164        }
165    }
166
167    /// The *Tokyo Night Moon* scheme (base16). `B00` is the canvas/background,
168    /// `B05` the default foreground, `B08..B0F` the accents (red, orange,
169    /// yellow, green, cyan, blue, magenta, dark-red).
170    pub fn tokyo_night_moon() -> Self {
171        Self {
172            is_dark: true,
173            colors: [
174                rgb(0x22, 0x24, 0x36), // B00 background
175                rgb(0x2f, 0x33, 0x4d), // B01 lighter bg (block body)
176                rgb(0x3b, 0x42, 0x61), // B02 selection / subtle lines
177                rgb(0x54, 0x5c, 0x7e), // B03 muted
178                rgb(0x82, 0x8b, 0xb8), // B04 dark foreground
179                rgb(0xc8, 0xd3, 0xf5), // B05 default foreground
180                rgb(0xd5, 0xdb, 0xf5), // B06 light foreground
181                rgb(0xe6, 0xea, 0xfe), // B07 lightest
182                rgb(0xff, 0x75, 0x7f), // B08 red
183                rgb(0xff, 0x96, 0x6c), // B09 orange
184                rgb(0xff, 0xc7, 0x77), // B0A yellow
185                rgb(0xc3, 0xe8, 0x8d), // B0B green
186                rgb(0x86, 0xe1, 0xfc), // B0C cyan
187                rgb(0x82, 0xaa, 0xff), // B0D blue
188                rgb(0xc0, 0x99, 0xff), // B0E magenta
189                rgb(0xc5, 0x3b, 0x53), // B0F dark red
190            ],
191        }
192    }
193
194    /// Whether this is a dark scheme.
195    pub fn is_dark(&self) -> bool {
196        self.is_dark
197    }
198
199    /// This palette with `saturation` applied to every slot — the canvas's
200    /// half of the read-only signal (spec §3.2).
201    pub fn toned(&self, saturation: Saturation) -> Self {
202        Self {
203            colors: self.colors.map(|color| saturation.applied(color)),
204            is_dark: self.is_dark,
205        }
206    }
207}
208
209/// Whether the canvas draws in its own colors or drained of them.
210///
211/// The third of §3.2's three redundant read-only signals, beside the viewing
212/// band and the dimmed tool band. A lens onto the past keeps its geometry
213/// exactly — only the color goes — so a reader can still see the drawing
214/// while never mistaking it for the live one.
215#[derive(Clone, Copy, PartialEq, Eq, Debug)]
216pub enum Saturation {
217    Full,
218    Drained,
219}
220
221impl Saturation {
222    /// How much of a color survives the drain. The mockup's
223    /// `filter: saturate(.35)`, which is enough to keep an accent
224    /// distinguishable from its neighbour and far too little to read as live.
225    const REMAINING: f32 = 0.35;
226
227    pub fn applied(self, color: Color) -> Color {
228        match self {
229            Saturation::Full => color,
230            Saturation::Drained => {
231                let grey = luma(color);
232                let mix = |channel: u8| {
233                    let kept = f32::from(channel) * Self::REMAINING;
234                    let lost = f32::from(grey) * (1.0 - Self::REMAINING);
235                    (kept + lost) as u8
236                };
237                Color::from_rgb(mix(color.r()), mix(color.g()), mix(color.b()))
238            }
239        }
240    }
241}
242
243/// Rec. 601 luma — the grey a color drains toward, so a drained palette keeps
244/// the light/dark contrast the scheme was built with.
245fn luma(color: Color) -> u8 {
246    (0.299 * f32::from(color.r()) + 0.587 * f32::from(color.g()) + 0.114 * f32::from(color.b()))
247        as u8
248}
249
250/// The six user-selectable base16 scheme families, each in a dark and a light
251/// variant. Values are the canonical [tinted-theming] base16 palettes; the base16
252/// slot order matches this module's [`Base`] (B00 background … B05 foreground;
253/// B08..B0F the accents red, orange, yellow, green, cyan, blue, magenta, brown).
254///
255/// [tinted-theming]: https://github.com/tinted-theming/schemes
256impl Palette {
257    /// Build a palette from 16 `0xRRGGBB` values in base16 slot order.
258    const fn base16(luminance: Luminance, hexes: [u32; 16]) -> Self {
259        let mut colors = [Color::TRANSPARENT; 16];
260        let mut i = 0;
261        while i < 16 {
262            let v = hexes[i];
263            colors[i] = Color::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8);
264            i += 1;
265        }
266        Self {
267            colors,
268            is_dark: luminance.is_dark(),
269        }
270    }
271
272    pub fn catppuccin_mocha() -> Self {
273        Self::base16(
274            Luminance::Dark,
275            [
276                0x1e1e2e, 0x181825, 0x313244, 0x45475a, 0x585b70, 0xcdd6f4, 0xf5e0dc, 0xb4befe,
277                0xf38ba8, 0xfab387, 0xf9e2af, 0xa6e3a1, 0x94e2d5, 0x89b4fa, 0xcba6f7, 0xf2cdcd,
278            ],
279        )
280    }
281
282    pub fn catppuccin_latte() -> Self {
283        Self::base16(
284            Luminance::Light,
285            [
286                0xeff1f5, 0xe6e9ef, 0xccd0da, 0xbcc0cc, 0xacb0be, 0x4c4f69, 0xdc8a78, 0x7287fd,
287                0xd20f39, 0xfe640b, 0xdf8e1d, 0x40a02b, 0x179299, 0x1e66f5, 0x8839ef, 0xdd7878,
288            ],
289        )
290    }
291
292    pub fn gruvbox_dark() -> Self {
293        Self::base16(
294            Luminance::Dark,
295            [
296                0x1d2021, 0x3c3836, 0x504945, 0x665c54, 0xbdae93, 0xd5c4a1, 0xebdbb2, 0xfbf1c7,
297                0xfb4934, 0xfe8019, 0xfabd2f, 0xb8bb26, 0x8ec07c, 0x83a598, 0xd3869b, 0xd65d0e,
298            ],
299        )
300    }
301
302    pub fn gruvbox_light() -> Self {
303        Self::base16(
304            Luminance::Light,
305            [
306                0xf9f5d7, 0xebdbb2, 0xd5c4a1, 0xbdae93, 0x665c54, 0x504945, 0x3c3836, 0x282828,
307                0x9d0006, 0xaf3a03, 0xb57614, 0x79740e, 0x427b58, 0x076678, 0x8f3f71, 0xd65d0e,
308            ],
309        )
310    }
311
312    pub fn rose_pine() -> Self {
313        Self::base16(
314            Luminance::Dark,
315            [
316                0x191724, 0x1f1d2e, 0x26233a, 0x6e6a86, 0x908caa, 0xe0def4, 0xe0def4, 0x524f67,
317                0xeb6f92, 0xf6c177, 0xebbcba, 0x31748f, 0x9ccfd8, 0xc4a7e7, 0xf6c177, 0x524f67,
318            ],
319        )
320    }
321
322    pub fn rose_pine_dawn() -> Self {
323        Self::base16(
324            Luminance::Light,
325            [
326                0xfaf4ed, 0xfffaf3, 0xf2e9de, 0x9893a5, 0x797593, 0x575279, 0x575279, 0xcecacd,
327                0xb4637a, 0xea9d34, 0xd7827e, 0x286983, 0x56949f, 0x907aa9, 0xea9d34, 0xcecacd,
328            ],
329        )
330    }
331
332    pub fn everforest_dark() -> Self {
333        Self::base16(
334            Luminance::Dark,
335            [
336                0x272e33, 0x2e383c, 0x414b50, 0x859289, 0x9da9a0, 0xd3c6aa, 0xedeada, 0xfffbef,
337                0xe67e80, 0xe69875, 0xdbbc7f, 0xa7c080, 0x83c092, 0x7fbbb3, 0xd699b6, 0x9da9a0,
338            ],
339        )
340    }
341
342    pub fn everforest_light() -> Self {
343        Self::base16(
344            Luminance::Light,
345            [
346                0xfffbef, 0xf8f5e4, 0xedeada, 0x939f91, 0x829181, 0x5c6a72, 0x414b50, 0x272e33,
347                0xf85552, 0xf57d26, 0xdfa000, 0x8da101, 0x35a77c, 0x3a94c5, 0xdf69ba, 0x829181,
348            ],
349        )
350    }
351
352    pub fn ayu_dark() -> Self {
353        Self::base16(
354            Luminance::Dark,
355            [
356                0x0b0e14, 0x131721, 0x202229, 0x3e4b59, 0xbfbdb6, 0xe6e1cf, 0xece8db, 0xf2f0e7,
357                0xf07178, 0xff8f40, 0xffb454, 0xaad94c, 0x95e6cb, 0x59c2ff, 0xd2a6ff, 0xe6b450,
358            ],
359        )
360    }
361
362    pub fn ayu_light() -> Self {
363        Self::base16(
364            Luminance::Light,
365            [
366                0xf8f9fa, 0xedeff1, 0xd2d4d8, 0xa0a6ac, 0x8a9199, 0x5c6166, 0x4e5257, 0x404447,
367                0xf07171, 0xfa8d3e, 0xf2ae49, 0x6cbf49, 0x4cbf99, 0x399ee6, 0xa37acc, 0xe6ba7e,
368            ],
369        )
370    }
371
372    pub fn tokyo_night_dark() -> Self {
373        Self::base16(
374            Luminance::Dark,
375            [
376                0x1a1b26, 0x16161e, 0x2f3549, 0x444b6a, 0x787c99, 0xa9b1d6, 0xcbccd1, 0xd5d6db,
377                0xc0caf5, 0xa9b1d6, 0x0db9d7, 0x9ece6a, 0xb4f9f8, 0x2ac3de, 0xbb9af7, 0xf7768e,
378            ],
379        )
380    }
381
382    pub fn tokyo_night_light() -> Self {
383        Self::base16(
384            Luminance::Light,
385            [
386                0xd5d6db, 0xcbccd1, 0xdfe0e5, 0x9699a3, 0x4c505e, 0x343b59, 0x1a1b26, 0x1a1b26,
387                0x343b58, 0x965027, 0x166775, 0x485e30, 0x3e6968, 0x34548a, 0x5a4a78, 0x8c4351,
388            ],
389        )
390    }
391}
392
393impl Palette {
394    /// Build egui's [`Visuals`](egui::Visuals) from this palette so the chrome
395    /// egui draws itself — the toolbar popup, buttons, checkbox, separators and
396    /// the in-place rename `TextEdit` — matches the canvas. Starts from the
397    /// light or dark preset (per the scheme's `is_dark`) and overrides the colors
398    /// that read against our background.
399    pub fn egui_visuals(&self) -> egui::Visuals {
400        use crate::canvas::egui_compat::IntoEgui as _;
401        use egui::Stroke;
402        let c = |b| self.get(b).egui();
403        let mut v = if self.is_dark {
404            egui::Visuals::dark()
405        } else {
406            egui::Visuals::light()
407        };
408
409        v.override_text_color = Some(c(Base::B05));
410        v.panel_fill = c(Base::B00);
411        v.window_fill = c(Base::B01);
412        v.window_stroke = Stroke::new(1.0, c(Base::B02));
413        v.extreme_bg_color = c(Base::B00); // text-edit background
414        v.faint_bg_color = c(Base::B01);
415        v.code_bg_color = c(Base::B01);
416        v.hyperlink_color = c(Base::B0D);
417        v.selection.bg_fill = c(Base::B0D).gamma_multiply(0.4);
418        v.selection.stroke = Stroke::new(1.0, c(Base::B05));
419
420        let w = &mut v.widgets;
421        w.noninteractive.bg_fill = c(Base::B01);
422        w.noninteractive.weak_bg_fill = c(Base::B01);
423        w.noninteractive.bg_stroke = Stroke::new(1.0, c(Base::B02));
424        w.noninteractive.fg_stroke = Stroke::new(1.0, c(Base::B05));
425
426        w.inactive.bg_fill = c(Base::B02);
427        w.inactive.weak_bg_fill = c(Base::B01);
428        w.inactive.bg_stroke = Stroke::new(1.0, c(Base::B02));
429        w.inactive.fg_stroke = Stroke::new(1.0, c(Base::B05));
430
431        w.hovered.bg_fill = c(Base::B03);
432        w.hovered.weak_bg_fill = c(Base::B02);
433        w.hovered.bg_stroke = Stroke::new(1.0, c(Base::B0D));
434        w.hovered.fg_stroke = Stroke::new(1.5, c(Base::B06));
435
436        w.active.bg_fill = c(Base::B0D);
437        w.active.weak_bg_fill = c(Base::B03);
438        w.active.bg_stroke = Stroke::new(1.0, c(Base::B0D));
439        w.active.fg_stroke = Stroke::new(2.0, c(Base::B00));
440
441        w.open.bg_fill = c(Base::B02);
442        w.open.weak_bg_fill = c(Base::B01);
443        w.open.bg_stroke = Stroke::new(1.0, c(Base::B02));
444        w.open.fg_stroke = Stroke::new(1.0, c(Base::B05));
445
446        v
447    }
448}
449
450const fn rgb(r: u8, g: u8, b: u8) -> Color {
451    Color::from_rgb(r, g, b)
452}