Skip to main content

blockworx/theme/
mod.rs

1use blockworx_paint::{Color, Font};
2use indexmap::IndexMap;
3use serde::{Deserialize, Serialize};
4use strum::IntoEnumIterator;
5
6use crate::canvas::{Base, Palette, Swatch};
7use blockworx_geom::WorldPx;
8
9pub mod role_picker;
10
11mod style;
12pub use style::Style;
13
14/// A semantic color slot. Drawing code passes a `Role` to every draw call; the
15/// renderer resolves it through the active [`Theme`] (role → [`Base`] → color),
16/// so call sites name *intent*, never a literal color. Add a variant here for
17/// each semantically-distinct color in the app.
18///
19/// `Transparent` must remain the last variant: [`N_ROLES`] is derived from it.
20#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, strum::EnumIter)]
21pub enum Role {
22    // Shapes (blocks and ports)
23    ShapeFill,
24    ShapeStroke,
25    ShapeTitle,
26    // Washed-out variants shown on a locked block, plus the block's type label
27    // color (unlocked and locked) and the corner lock-hint icon color.
28    LockedShapeFill,
29    LockedShapeTitle,
30    ShapeType,
31    LockedShapeType,
32    LockedHintIcon,
33
34    // Accent (per-block outline) colors: a block's optional `role` picks one of
35    // `Accent0..Accent7`; a block with no role uses `AccentDefault`.
36    AccentDefault,
37    Accent0,
38    Accent1,
39    Accent2,
40    Accent3,
41    Accent4,
42    Accent5,
43    Accent6,
44    Accent7,
45
46    // Text-box annotations
47    TextBoxFill,
48    TextBoxStroke,
49
50    // Boundary areas (outline only, drawn above blocks and routes)
51    AreaStroke,
52
53    // Routes / wires
54    RouteNormal,
55    RouteSelected,
56    RouteHighlighted,
57    RouteInProgress,
58    RouteProposedEndpoint,
59
60    // Pins
61    PinStem,
62    PinText,
63    PinTag,
64    PinStemSelected,
65    PinTagSelected,
66    /// Faint "Add Name"/"Add Type" placeholder shown at an empty pin label slot
67    /// while the pin's shape is selected.
68    PinLabelPlaceholder,
69
70    // Drag / move states
71    DragPreviewStroke,
72    DragActiveFill,
73    DragActiveStroke,
74    EdgeDragPreview,
75
76    // Selection & editing controls
77    SelectionFrame,
78    SelectionFrameOutline,
79    ControlHandleFill,
80    ControlHandleStroke,
81    WaypointFill,
82    ResizeCornerFill,
83    ResizeCornerStroke,
84    ResizeCornerActiveFill,
85    ResizeCornerActiveStroke,
86    ImageDragBox,
87    MarqueeFill,
88
89    // Tool previews
90    NewBlockPreviewStroke,
91    NewPinPreviewFill,
92    NewPinPreviewStroke,
93    /// The grow-out target shown on a pin/anchor when hovering it in the select
94    /// tool (to start a route) or nearing it while routing (to end one).
95    RouteStartTarget,
96
97    // UI chrome
98    CanvasBackground,
99    GridLine,
100    PinDragIndicator,
101
102    // Debug
103    DebugTextBbox,
104    DebugMark,
105
106    /// Thin line shown while dragging when an edge/center/pin-stub lines up with
107    /// another shape's. A base color, not an accent.
108    AlignmentGuide,
109
110    /// The grid-cell size readout shown inside a block's upper-left corner while
111    /// it is being resized. A base color, like the other transient drag hints.
112    SizeReadout,
113
114    /// The liveness dot at the head of the top bar, in its four states
115    /// (R50): recorded, a write in flight, nothing writable, and a scratch
116    /// session with no file behind it. Hues rather than base tones — they
117    /// say *which* state, not how loud — except the scratch one, which is a
118    /// base tone on purpose: it is the absence of a state, not one of them.
119    LiveDot,
120    DotWriting,
121    DotReadOnly,
122    DotScratch,
123
124    /// The top bar while the time machine is open (spec §2.0): the amber wash
125    /// over the bar's own glass, the ink the centre's words and the dot take,
126    /// and the word on the one filled control in the shell. The wash carries
127    /// its own alpha, because it goes *over* the fill the bar already has —
128    /// the bar changes state rather than becoming another object.
129    ViewingTint,
130    ViewingInk,
131    ViewingReturnInk,
132
133    /// The toast that says what a file operation did (playbook R38): the
134    /// slab and the words on it. The mockup's is a dark glass plate with
135    /// white text, which is one scheme's answer to a question the palette
136    /// answers for both — so it takes the inverse-video pairing
137    /// [`Role::TagBadge`] argues for, like every other thing in this app
138    /// that has to be read over whatever is behind it.
139    Toast,
140    ToastText,
141
142    /// A named rev's badge in the history list: the chip behind the tag,
143    /// and the text on it. Inverse video off the base ramp — the
144    /// foreground slot filled, the background slot written on it — which
145    /// is the only pairing that clears WCAG AA in every scheme this app
146    /// ships, light and dark. A badge whose text you cannot read is not a
147    /// badge.
148    TagBadge,
149    TagBadgeText,
150
151    /// The initials disc beside a history row (§8.1). One of five accent
152    /// slots, picked by hashing the author's name, so the same hand keeps
153    /// the same colour down the list without anybody assigning one.
154    ///
155    /// Roles rather than arithmetic on the palette: the avatar wants
156    /// *categorical* colour, which is what the accent ramp is for, and
157    /// naming the five means a scheme can re-point them.
158    AuthorAvatar0,
159    AuthorAvatar1,
160    AuthorAvatar2,
161    AuthorAvatar3,
162    AuthorAvatar4,
163    /// The initials themselves, read over any of the five.
164    AuthorAvatarText,
165
166    /// The outline around a title block, in both homes it has: the editor's
167    /// corner of the canvas and the printed sheet's footer. Sheet furniture,
168    /// not a document object — which is why it does not borrow
169    /// [`Role::AreaStroke`], whose color a user edits to restyle their own
170    /// boundary areas.
171    TitleBlockBorder,
172
173    /// The ring a document step leaves round what it changed (R55). A guide
174    /// role, so a base tone — it points at the drawing for a moment, like
175    /// [`Role::AlignmentGuide`] and [`Role::SizeReadout`] beside it, and is
176    /// not content with an accent of its own.
177    ChangeRing,
178
179    /// Resolves to a fully transparent color (transparent fills, no-op strokes).
180    /// Keep this the last variant — [`N_ROLES`] depends on it.
181    Transparent,
182}
183
184/// Number of [`Role`] variants. Used to size the [`Theme`]'s tone table.
185pub const N_ROLES: usize = Role::Transparent as usize + 1;
186
187/// Map an optional accent index to its accent [`Role`]: `Some(0..=7)` →
188/// [`Role::Accent0`]..[`Role::Accent7`], `None` or out of range → `None`.
189/// Shared by every shape that carries an `Option<u8>` accent `role` (blocks,
190/// ports, routes); each call site supplies its own fallback for the `None` case.
191pub fn accent_role(role: Option<u8>) -> Option<Role> {
192    Some(match role? {
193        0 => Role::Accent0,
194        1 => Role::Accent1,
195        2 => Role::Accent2,
196        3 => Role::Accent3,
197        4 => Role::Accent4,
198        5 => Role::Accent5,
199        6 => Role::Accent6,
200        7 => Role::Accent7,
201        _ => return None,
202    })
203}
204
205/// A stroke described by a world-space width plus a [`Role`] — the app-side,
206/// role-based counterpart to the canvas's
207/// [`PaletteStroke`](crate::canvas::PaletteStroke). The [`Style`]
208/// adapter resolves the role to a [`Swatch`] (and hands the width through) when
209/// forwarding to the palette-based renderer. Construct from a `(width, role)`
210/// tuple, which wraps the literal width as a [`WorldPx`]; use
211/// [`RoleStroke::NONE`] for no stroke.
212#[derive(Clone, Copy)]
213pub struct RoleStroke {
214    pub width: WorldPx,
215    pub role: Role,
216}
217
218impl RoleStroke {
219    /// A zero-width, transparent stroke (draws nothing).
220    pub const NONE: RoleStroke = RoleStroke {
221        width: WorldPx::ZERO,
222        role: Role::Transparent,
223    };
224}
225
226impl From<(f32, Role)> for RoleStroke {
227    fn from((width, role): (f32, Role)) -> Self {
228        RoleStroke {
229            width: WorldPx::new(width),
230            role,
231        }
232    }
233}
234
235/// The six canvas text sizes (in px), the font counterpart to the role → base
236/// color mapping. Layered onto [`Theme`] from the embedded `font_sizes.json`
237/// (see [`Theme::from_embedded`]) and tuned live by the font editor
238/// (`--font-editor`). `#[serde(default)]` lets a partial file specify only the
239/// sizes it overrides; the rest fall back to [`FontSizes::default`].
240#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
241#[serde(default)]
242pub struct FontSizes {
243    pub title: f32,
244    pub block_type: f32,
245    pub pin: f32,
246    pub pin_subtitle: f32,
247    pub tag: f32,
248    pub route: f32,
249}
250
251impl Default for FontSizes {
252    fn default() -> Self {
253        use crate::grid;
254        Self {
255            title: grid::TITLE_TEXT_SIZE,
256            block_type: grid::BLOCK_TYPE_TEXT_SIZE,
257            pin: grid::PORT_TEXT_SIZE,
258            pin_subtitle: grid::PORT_SUBTITLE_TEXT_SIZE,
259            tag: grid::TAG_TEXT_SIZE,
260            route: grid::ROUTE_TEXT_SIZE,
261        }
262    }
263}
264
265#[derive(Clone)]
266pub struct Theme {
267    // Fonts
268    pub title_font: Font,
269    /// A block's type label, drawn slightly smaller than `title_font`.
270    pub type_font: Font,
271    pub pin_font: Font,
272    /// Second line of a pin/port name, drawn smaller than `pin_font`.
273    pub pin_subtitle_font: Font,
274    pub tag_font: Font,
275    pub route_font: Font,
276    /// The sizes the six font fields above were built from, so the font editor
277    /// can read them back and re-derive the `Font`s on a change.
278    font_sizes: FontSizes,
279
280    /// The 16 base colors every role resolves into.
281    palette: Palette,
282    /// Role → [`Swatch`] mapping (base slot + alpha), indexed by `role as usize`.
283    tones: [Swatch; N_ROLES],
284}
285
286impl Theme {
287    /// The concrete color for a role: its [`Swatch`] resolved against the
288    /// palette. [`Role::Transparent`] and any role mapped to `None` resolve to a
289    /// fully transparent color. Kept for code that draws with the raw egui
290    /// painter (the canvas chrome, the theme editor / role picker swatches); the
291    /// render path passes a `Swatch`/`PaletteStroke` to the canvas instead.
292    pub fn resolve(&self, role: Role) -> Color {
293        self.palette.resolve(self.swatch(role))
294    }
295
296    /// The palette [`Swatch`] a role resolves to (its base slot plus alpha).
297    /// [`Role::Transparent`] is always the transparent swatch. This is the
298    /// hand-off point to the palette-based canvas: app roles become swatches
299    /// here, and the renderer turns swatches into colors.
300    pub fn swatch(&self, role: Role) -> Swatch {
301        if role == Role::Transparent {
302            return Swatch::TRANSPARENT;
303        }
304        self.tones[role as usize]
305    }
306
307    /// The palette backing this theme (for deriving egui's [`egui::Visuals`]).
308    pub fn palette(&self) -> &Palette {
309        &self.palette
310    }
311
312    /// Swap the backing palette (e.g. when the user picks a different scheme).
313    /// The role → base `tones` table is palette-independent, so every role — and
314    /// all egui chrome derived via [`Palette::egui_visuals`] — restyles at once.
315    pub fn set_palette(&mut self, palette: Palette) {
316        self.palette = palette;
317    }
318
319    /// The palette slot a role resolves through, or `None` if it draws nothing
320    /// (transparent). Alpha is ignored here.
321    pub fn base_of(&self, role: Role) -> Option<Base> {
322        self.tones[role as usize].base
323    }
324
325    /// Repoint a role at a different palette slot (or `None` for transparent),
326    /// preserving its alpha. [`Role::Transparent`] is fixed, so it is untouched.
327    pub fn set_base(&mut self, role: Role, base: Option<Base>) {
328        if role != Role::Transparent {
329            self.tones[role as usize].base = base;
330        }
331    }
332
333    /// The full role → base mapping, in declaration order, excluding
334    /// [`Role::Transparent`] (which is always transparent). A role with no base
335    /// serializes as `null`. This is what the theme editor writes to `theme.json`.
336    pub fn overrides(&self) -> IndexMap<Role, Option<Base>> {
337        Role::iter()
338            .filter(|r| *r != Role::Transparent)
339            .map(|r| (r, self.base_of(r)))
340            .collect()
341    }
342
343    /// The active canvas font sizes (px).
344    pub fn font_sizes(&self) -> FontSizes {
345        self.font_sizes
346    }
347
348    /// Retune the six canvas fonts to `fs` (rebuilding each `Font`) and record
349    /// the sizes. The font editor writes these to `font_sizes.json` on exit.
350    pub fn set_font_sizes(&mut self, fs: FontSizes) {
351        self.apply_font_sizes(fs);
352    }
353
354    /// Rebuild the six `Font` fields from `fs` and remember the sizes.
355    fn apply_font_sizes(&mut self, fs: FontSizes) {
356        self.title_font = Font::canvas(fs.title);
357        self.type_font = Font::canvas(fs.block_type);
358        self.pin_font = Font::canvas(fs.pin);
359        self.pin_subtitle_font = Font::canvas(fs.pin_subtitle);
360        self.tag_font = Font::canvas(fs.tag);
361        self.route_font = Font::canvas(fs.route);
362        self.font_sizes = fs;
363    }
364
365    /// The compile-time default theme: [`Theme::default`] (fonts, alphas, and a
366    /// fallback base for every role) with the role → base overrides from the
367    /// embedded `theme.json`, then the embedded `font_sizes.json`, layered on
368    /// top. A `null` value maps the role to no color. A malformed file falls back
369    /// to the built-in defaults rather than failing to start.
370    pub fn from_embedded() -> Self {
371        let mut theme = Theme::default();
372        match serde_json::from_str::<IndexMap<Role, Option<Base>>>(EMBEDDED_THEME) {
373            Ok(overrides) => {
374                for (role, base) in overrides {
375                    theme.set_base(role, base);
376                }
377            }
378            Err(e) => tracing::warn!("theme.json parse error, using built-in defaults: {e}"),
379        }
380        match serde_json::from_str::<FontSizes>(EMBEDDED_FONT_SIZES) {
381            Ok(fs) => theme.apply_font_sizes(fs),
382            Err(e) => tracing::warn!("font_sizes.json parse error, using defaults: {e}"),
383        }
384        theme
385    }
386}
387
388/// The role → base overrides baked into the binary, written by the theme editor
389/// (`--theme-editor`) on exit. See [`Theme::from_embedded`].
390const EMBEDDED_THEME: &str = include_str!("theme.json");
391
392/// The canvas font sizes baked into the binary, written by the font editor
393/// (`--font-editor`) on exit. See [`Theme::from_embedded`].
394const EMBEDDED_FONT_SIZES: &str = include_str!("font_sizes.json");
395
396impl Default for Theme {
397    fn default() -> Self {
398        use Base::{
399            B00, B0A, B0B, B0C, B0D, B0E, B0F, B01, B02, B03, B04, B05, B06, B07, B08, B09,
400        };
401        use Role::{
402            Accent0, Accent1, Accent2, Accent3, Accent4, Accent5, Accent6, Accent7, AccentDefault,
403            AlignmentGuide, AreaStroke, AuthorAvatar0, AuthorAvatar1, AuthorAvatar2, AuthorAvatar3,
404            AuthorAvatar4, AuthorAvatarText, CanvasBackground, ChangeRing, ControlHandleFill,
405            ControlHandleStroke, DebugMark, DebugTextBbox, DotReadOnly, DotScratch, DotWriting,
406            DragActiveFill, DragActiveStroke, DragPreviewStroke, EdgeDragPreview, GridLine,
407            ImageDragBox, LiveDot, LockedHintIcon, LockedShapeFill, LockedShapeTitle,
408            LockedShapeType, MarqueeFill, NewBlockPreviewStroke, NewPinPreviewFill,
409            NewPinPreviewStroke, PinDragIndicator, PinLabelPlaceholder, PinStem, PinStemSelected,
410            PinTag, PinTagSelected, PinText, ResizeCornerActiveFill, ResizeCornerActiveStroke,
411            ResizeCornerFill, ResizeCornerStroke, RouteHighlighted, RouteInProgress, RouteNormal,
412            RouteProposedEndpoint, RouteSelected, RouteStartTarget, SelectionFrame,
413            SelectionFrameOutline, ShapeFill, ShapeStroke, ShapeTitle, ShapeType, SizeReadout,
414            TagBadge, TagBadgeText, TextBoxFill, TextBoxStroke, TitleBlockBorder, Toast, ToastText,
415            ViewingInk, ViewingReturnInk, ViewingTint, WaypointFill,
416        };
417
418        // Default every slot to the foreground color, then assign each role.
419        // Indexing by `role as usize` keeps this robust to variant reordering.
420        let mut tones = [Swatch::solid(B05); N_ROLES];
421        let mut set = |role: Role, tone: Swatch| tones[role as usize] = tone;
422
423        // Shapes
424        set(ShapeFill, Swatch::solid(B01));
425        set(ShapeStroke, Swatch::solid(B0D));
426        set(ShapeTitle, Swatch::solid(B0B));
427        set(LockedShapeFill, Swatch::solid(B02));
428        set(LockedShapeTitle, Swatch::solid(B0A));
429        set(ShapeType, Swatch::solid(B0A));
430        set(LockedShapeType, Swatch::solid(B09));
431        set(LockedHintIcon, Swatch::solid(B0A));
432
433        // Accent (per-block outline) colors: default + the 8 accents B08..B0F.
434        set(AccentDefault, Swatch::solid(B04));
435        set(Accent0, Swatch::solid(B08));
436        set(Accent1, Swatch::solid(B09));
437        set(Accent2, Swatch::solid(B0A));
438        set(Accent3, Swatch::solid(B0B));
439        set(Accent4, Swatch::solid(B0C));
440        set(Accent5, Swatch::solid(B0D));
441        set(Accent6, Swatch::solid(B0E));
442        set(Accent7, Swatch::solid(B0F));
443
444        // Text boxes
445        set(TextBoxFill, Swatch::faded(B01, 0.35));
446        set(TextBoxStroke, Swatch::solid(B03));
447
448        // Boundary areas: a solid, muted outline that reads as a grouping
449        // frame without competing with block/route strokes.
450        set(AreaStroke, Swatch::solid(B06));
451
452        // Routes
453        set(RouteNormal, Swatch::solid(B0B));
454        // A selected route draws a wider halo in this color behind its resting
455        // wire; B07 (the lightest foreground) reads as a highlight, matching the
456        // `*Selected` pin roles.
457        set(RouteSelected, Swatch::solid(B07));
458        set(RouteHighlighted, Swatch::faded(B0B, 0.3));
459        set(RouteInProgress, Swatch::solid(B0A));
460        set(RouteProposedEndpoint, Swatch::solid(B0F));
461
462        // Pins
463        set(PinStem, Swatch::solid(B0F));
464        set(PinText, Swatch::solid(B05));
465        set(PinTag, Swatch::solid(B0B));
466        set(PinStemSelected, Swatch::solid(B08));
467        set(PinTagSelected, Swatch::solid(B0C));
468        set(PinLabelPlaceholder, Swatch::solid(B04));
469
470        // Drag / move
471        set(DragPreviewStroke, Swatch::solid(B04));
472        set(DragActiveFill, Swatch::solid(B02));
473        set(DragActiveStroke, Swatch::solid(B0F));
474        set(EdgeDragPreview, Swatch::faded(B04, 0.2));
475
476        // Selection & controls
477        set(SelectionFrame, Swatch::solid(B0F));
478        set(SelectionFrameOutline, Swatch::solid(B0D));
479        set(ControlHandleFill, Swatch::solid(B07));
480        set(ControlHandleStroke, Swatch::solid(B00));
481        set(WaypointFill, Swatch::faded(B0B, 0.5));
482        set(ResizeCornerFill, Swatch::solid(B0B));
483        set(ResizeCornerStroke, Swatch::solid(B0B));
484        set(ResizeCornerActiveFill, Swatch::solid(B0C));
485        set(ResizeCornerActiveStroke, Swatch::solid(B07));
486        set(ImageDragBox, Swatch::solid(B0D));
487        set(MarqueeFill, Swatch::faded(B0F, 0.1));
488
489        // Tool previews
490        set(NewBlockPreviewStroke, Swatch::solid(B0D));
491        set(NewPinPreviewFill, Swatch::solid(B0D));
492        set(NewPinPreviewStroke, Swatch::solid(B07));
493        set(RouteStartTarget, Swatch::solid(B0B));
494
495        // UI chrome
496        set(CanvasBackground, Swatch::solid(B00));
497        set(GridLine, Swatch::faded(B02, 0.6));
498        set(PinDragIndicator, Swatch::faded(B04, 0.3));
499
500        // Debug
501        set(DebugTextBbox, Swatch::faded(B09, 0.7));
502        set(DebugMark, Swatch::faded(B08, 0.6));
503
504        // Alignment guides — a dim base color, fainter than the shape outline
505        // (a base color, not an accent) so the hints don't dominate.
506        set(AlignmentGuide, Swatch::solid(B07));
507
508        // The resize size readout: the brightest base, so it reads over the
509        // block's active drag fill without competing with an accent.
510        set(SizeReadout, Swatch::solid(B07));
511
512        // The liveness dot and the lens's amber (R42, §2.0). Two hues off the
513        // ramp rather than two base tones: these say which state the document
514        // is in, and a base tone would only say how loud it is. The wash is
515        // faded because it goes over the bar's own glass — a solid one would
516        // make the bar a different object instead of the same bar tinted.
517        set(LiveDot, Swatch::solid(B0B));
518        set(DotWriting, Swatch::solid(B0A));
519        set(DotReadOnly, Swatch::solid(B08));
520        set(DotScratch, Swatch::solid(B04));
521        set(ViewingTint, Swatch::faded(B09, 0.28));
522        set(ViewingInk, Swatch::solid(B09));
523        set(ViewingReturnInk, Swatch::solid(B00));
524
525        // The toast: the same pairing again, and for the same reason — it
526        // stands over the drawing for two seconds and has to be read in
527        // that time, whatever is under it.
528        set(Toast, Swatch::solid(B07));
529        set(ToastText, Swatch::solid(B00));
530
531        // The title block's frame, on screen and in print: the same weight as
532        // a block outline, so it reads as ruled sheet furniture rather than
533        // as another thing on the drawing.
534        set(TitleBlockBorder, Swatch::solid(B04));
535
536        // What a step changed: the brightest base, so the ring reads as a
537        // hint laid over the drawing rather than as another thing in it.
538        set(ChangeRing, Swatch::solid(B07));
539
540        // A named rev's badge: inverse video, so the chip and its text
541        // stay legible whichever way round the scheme runs.
542        set(TagBadge, Swatch::solid(B05));
543        set(TagBadgeText, Swatch::solid(B00));
544
545        // The avatar's five: the accents §2.2 leaves for categorical use,
546        // with the darkest base written on them so the initials read on
547        // every one of the five in both schemes.
548        set(AuthorAvatar0, Swatch::solid(B0D));
549        set(AuthorAvatar1, Swatch::solid(B0B));
550        set(AuthorAvatar2, Swatch::solid(B09));
551        set(AuthorAvatar3, Swatch::solid(B0E));
552        set(AuthorAvatar4, Swatch::solid(B0C));
553        set(AuthorAvatarText, Swatch::solid(B00));
554
555        let fs = FontSizes::default();
556        Self {
557            title_font: Font::canvas(fs.title),
558            type_font: Font::canvas(fs.block_type),
559            pin_font: Font::canvas(fs.pin),
560            pin_subtitle_font: Font::canvas(fs.pin_subtitle),
561            tag_font: Font::canvas(fs.tag),
562            route_font: Font::canvas(fs.route),
563            font_sizes: fs,
564            palette: Palette::tokyo_night_moon(),
565            tones,
566        }
567    }
568}
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use crate::canvas::Base;
574
575    /// WCAG relative luminance, for the one claim a palette-independent
576    /// role pair has to make.
577    fn luminance(color: Color) -> f32 {
578        let channel = |v: u8| {
579            let v = f32::from(v) / 255.0;
580            if v <= 0.039_28 {
581                v / 12.92
582            } else {
583                ((v + 0.055) / 1.055).powf(2.4)
584            }
585        };
586        0.2126 * channel(color.r()) + 0.7152 * channel(color.g()) + 0.0722 * channel(color.b())
587    }
588
589    fn contrast(a: Color, b: Color) -> f32 {
590        let (a, b) = (luminance(a), luminance(b));
591        (a.max(b) + 0.05) / (a.min(b) + 0.05)
592    }
593
594    /// The tag badge has to be readable in every scheme, both ways round.
595    #[test]
596    fn the_tag_badge_reads_in_every_scheme_and_both_luminances() {
597        use crate::canvas::palette::Luminance;
598        for scheme in crate::preferences::Theme::ALL {
599            for luminance in [Luminance::Dark, Luminance::Light] {
600                let mut theme = Theme::default();
601                theme.set_palette(scheme.palette(luminance));
602                let ratio = contrast(
603                    theme.resolve(Role::TagBadge),
604                    theme.resolve(Role::TagBadgeText),
605                );
606                assert!(
607                    ratio >= 4.5,
608                    "{scheme:?}/{luminance:?}: the tag badge reads at {ratio:.1}:1",
609                );
610            }
611        }
612    }
613
614    #[test]
615    fn locked_and_type_roles_resolve_to_expected_bases() {
616        let theme = Theme::default();
617        assert_eq!(theme.base_of(Role::LockedShapeFill), Some(Base::B02));
618        assert_eq!(theme.base_of(Role::LockedShapeTitle), Some(Base::B0A));
619        assert_eq!(theme.base_of(Role::ShapeType), Some(Base::B0A));
620        assert_eq!(theme.base_of(Role::LockedShapeType), Some(Base::B09));
621        assert_eq!(theme.base_of(Role::LockedHintIcon), Some(Base::B0A));
622    }
623
624    #[test]
625    fn font_sizes_default_matches_grid_constants() {
626        use crate::grid;
627        let fs = FontSizes::default();
628        assert_eq!(fs.title, grid::TITLE_TEXT_SIZE);
629        assert_eq!(fs.block_type, grid::BLOCK_TYPE_TEXT_SIZE);
630        assert_eq!(fs.pin, grid::PORT_TEXT_SIZE);
631        assert_eq!(fs.pin_subtitle, grid::PORT_SUBTITLE_TEXT_SIZE);
632        assert_eq!(fs.tag, grid::TAG_TEXT_SIZE);
633        assert_eq!(fs.route, grid::ROUTE_TEXT_SIZE);
634    }
635
636    #[test]
637    fn font_sizes_json_round_trip() {
638        let fs = FontSizes::default();
639        let json = serde_json::to_string(&fs).unwrap();
640        let back: FontSizes = serde_json::from_str(&json).unwrap();
641        assert_eq!(fs, back);
642    }
643
644    #[test]
645    fn font_sizes_partial_json_uses_defaults() {
646        let fs: FontSizes = serde_json::from_str(r#"{"title":20.0}"#).unwrap();
647        let default = FontSizes::default();
648        assert_eq!(fs.title, 20.0);
649        assert_eq!(fs.block_type, default.block_type);
650        assert_eq!(fs.pin, default.pin);
651        assert_eq!(fs.pin_subtitle, default.pin_subtitle);
652        assert_eq!(fs.tag, default.tag);
653        assert_eq!(fs.route, default.route);
654    }
655
656    #[test]
657    fn set_font_sizes_rebuilds_font_ids() {
658        let mut theme = Theme::default();
659        let custom = FontSizes {
660            title: 21.0,
661            block_type: 18.0,
662            pin: 17.0,
663            pin_subtitle: 10.0,
664            tag: 13.0,
665            route: 14.0,
666        };
667        theme.set_font_sizes(custom);
668        assert_eq!(theme.title_font.size, custom.title);
669        assert_eq!(theme.type_font.size, custom.block_type);
670        assert_eq!(theme.pin_font.size, custom.pin);
671        assert_eq!(theme.pin_subtitle_font.size, custom.pin_subtitle);
672        assert_eq!(theme.tag_font.size, custom.tag);
673        assert_eq!(theme.route_font.size, custom.route);
674        assert_eq!(theme.font_sizes(), custom);
675    }
676
677    #[test]
678    fn embedded_font_sizes_parses() {
679        assert!(serde_json::from_str::<FontSizes>(EMBEDDED_FONT_SIZES).is_ok());
680    }
681}