1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use strum::IntoEnumIterator;
4
5use blockworx_geom::WorldPx;
6
7use crate::{Base, Color, Font, Palette, Swatch};
8
9mod style;
10pub use style::Style;
11
12#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, strum::EnumIter)]
19pub enum Role {
20 ShapeFill,
22 ShapeStroke,
23 ShapeTitle,
24 LockedShapeFill,
27 LockedShapeTitle,
28 ShapeType,
29 LockedShapeType,
30 LockedHintIcon,
31
32 AccentDefault,
35 Accent0,
36 Accent1,
37 Accent2,
38 Accent3,
39 Accent4,
40 Accent5,
41 Accent6,
42 Accent7,
43
44 TextBoxFill,
46 TextBoxStroke,
47
48 AreaStroke,
50
51 RouteNormal,
53 RouteSelected,
54 RouteHighlighted,
55 RouteInProgress,
56 RouteProposedEndpoint,
57
58 PinStem,
60 PinText,
61 PinTag,
62 PinStemSelected,
63 PinTagSelected,
64 PinLabelPlaceholder,
67
68 DragPreviewStroke,
70 DragActiveFill,
71 DragActiveStroke,
72 EdgeDragPreview,
73 MoveRefusedStroke,
77 StandingConflict,
81
82 SelectionFrame,
84 SelectionFrameOutline,
85 ControlHandleFill,
86 ControlHandleStroke,
87 WaypointFill,
88 ResizeCornerFill,
89 ResizeCornerStroke,
90 ResizeCornerActiveFill,
91 ResizeCornerActiveStroke,
92 ImageDragBox,
93 MarqueeFill,
94
95 NewBlockPreviewStroke,
97 NewPinPreviewFill,
98 NewPinPreviewStroke,
99 RouteStartTarget,
102
103 CanvasBackground,
105 GridLine,
106 PinDragIndicator,
107
108 DebugMark,
110
111 AlignmentGuide,
114
115 SizeReadout,
118
119 LiveDot,
125 DotWriting,
126 DotReadOnly,
127 DotScratch,
128
129 ViewingTint,
135 ViewingInk,
136 ViewingReturnInk,
137
138 Toast,
145 ToastText,
146
147 TagBadge,
154 TagBadgeText,
155
156 AuthorAvatar0,
164 AuthorAvatar1,
165 AuthorAvatar2,
166 AuthorAvatar3,
167 AuthorAvatar4,
168 AuthorAvatarText,
170
171 TitleBlockBorder,
177
178 ChangeRing,
183
184 EditorText,
188 EditorFill,
189 EditorCaret,
191 EditorSelection,
192 EditorHint,
194 EditorBorder,
197
198 Transparent,
201}
202
203#[must_use]
207pub fn avatar_role(name: &str) -> Role {
208 const WHEEL: [Role; 5] = [
209 Role::AuthorAvatar0,
210 Role::AuthorAvatar1,
211 Role::AuthorAvatar2,
212 Role::AuthorAvatar3,
213 Role::AuthorAvatar4,
214 ];
215 let sum: usize = name.bytes().map(usize::from).sum();
216 WHEEL[sum % WHEEL.len()]
217}
218
219pub const N_ROLES: usize = Role::Transparent as usize + 1;
221
222pub fn accent_role(role: Option<u8>) -> Option<Role> {
227 Some(match role? {
228 0 => Role::Accent0,
229 1 => Role::Accent1,
230 2 => Role::Accent2,
231 3 => Role::Accent3,
232 4 => Role::Accent4,
233 5 => Role::Accent5,
234 6 => Role::Accent6,
235 7 => Role::Accent7,
236 _ => return None,
237 })
238}
239
240#[derive(Clone, Copy)]
248pub struct RoleStroke {
249 pub width: WorldPx,
250 pub role: Role,
251}
252
253impl RoleStroke {
254 pub const NONE: RoleStroke = RoleStroke {
256 width: WorldPx::ZERO,
257 role: Role::Transparent,
258 };
259}
260
261impl From<(f32, Role)> for RoleStroke {
262 fn from((width, role): (f32, Role)) -> Self {
263 RoleStroke {
264 width: WorldPx::new(width),
265 role,
266 }
267 }
268}
269
270#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
276#[serde(default)]
277pub struct FontSizes {
278 pub title: f32,
279 pub block_type: f32,
280 pub pin: f32,
281 pub pin_subtitle: f32,
282 pub tag: f32,
283 pub route: f32,
284}
285
286impl Default for FontSizes {
287 fn default() -> Self {
288 use blockworx_geom::grid;
289 Self {
290 title: grid::TITLE_TEXT_SIZE,
291 block_type: grid::BLOCK_TYPE_TEXT_SIZE,
292 pin: grid::PORT_TEXT_SIZE,
293 pin_subtitle: grid::PORT_SUBTITLE_TEXT_SIZE,
294 tag: grid::TAG_TEXT_SIZE,
295 route: grid::ROUTE_TEXT_SIZE,
296 }
297 }
298}
299
300#[derive(Clone)]
301pub struct Theme {
302 pub title_font: Font,
304 pub type_font: Font,
306 pub pin_font: Font,
307 pub pin_subtitle_font: Font,
309 pub tag_font: Font,
310 pub route_font: Font,
311 font_sizes: FontSizes,
314
315 palette: Palette,
317 tones: [Swatch; N_ROLES],
319}
320
321impl Theme {
322 pub fn editor_colors(&self, ink: Role, fill: Role) -> crate::EditColors {
333 crate::EditColors {
334 text: self.resolve(ink),
335 background: self.resolve(fill),
336 caret: self.resolve(Role::EditorCaret),
337 selection: self.resolve(Role::EditorSelection),
338 hint: self.resolve(Role::EditorHint),
339 border: self.resolve(Role::EditorBorder),
340 }
341 }
342
343 pub fn resolve(&self, role: Role) -> Color {
344 self.palette.resolve(self.swatch(role))
345 }
346
347 pub fn swatch(&self, role: Role) -> Swatch {
352 if role == Role::Transparent {
353 return Swatch::TRANSPARENT;
354 }
355 self.tones[role as usize]
356 }
357
358 pub fn palette(&self) -> &Palette {
361 &self.palette
362 }
363
364 pub fn set_palette(&mut self, palette: Palette) {
368 self.palette = palette;
369 }
370
371 pub fn base_of(&self, role: Role) -> Option<Base> {
374 self.tones[role as usize].base
375 }
376
377 pub fn set_base(&mut self, role: Role, base: Option<Base>) {
380 if role != Role::Transparent {
381 self.tones[role as usize].base = base;
382 }
383 }
384
385 pub fn overrides(&self) -> IndexMap<Role, Option<Base>> {
389 Role::iter()
390 .filter(|r| *r != Role::Transparent)
391 .map(|r| (r, self.base_of(r)))
392 .collect()
393 }
394
395 pub fn font_sizes(&self) -> FontSizes {
397 self.font_sizes
398 }
399
400 pub fn set_font_sizes(&mut self, fs: FontSizes) {
403 self.apply_font_sizes(fs);
404 }
405
406 fn apply_font_sizes(&mut self, fs: FontSizes) {
408 self.title_font = Font::canvas(fs.title);
409 self.type_font = Font::canvas(fs.block_type);
410 self.pin_font = Font::canvas(fs.pin);
411 self.pin_subtitle_font = Font::canvas(fs.pin_subtitle);
412 self.tag_font = Font::canvas(fs.tag);
413 self.route_font = Font::canvas(fs.route);
414 self.font_sizes = fs;
415 }
416
417 pub fn from_embedded() -> Self {
423 let mut theme = Theme::default();
424 match embedded_overrides() {
425 Ok(overrides) => {
426 for (role, base) in overrides {
427 theme.set_base(role, base);
428 }
429 }
430 Err(e) => tracing::warn!("theme.json parse error, using built-in defaults: {e}"),
431 }
432 match embedded_font_sizes() {
433 Ok(fs) => theme.apply_font_sizes(fs),
434 Err(e) => tracing::warn!("font_sizes.json parse error, using defaults: {e}"),
435 }
436 theme
437 }
438}
439
440fn embedded_overrides() -> Result<IndexMap<Role, Option<Base>>, serde_json::Error> {
444 serde_json::from_str(EMBEDDED_THEME)
445}
446
447fn embedded_font_sizes() -> Result<FontSizes, serde_json::Error> {
448 serde_json::from_str(EMBEDDED_FONT_SIZES)
449}
450
451const EMBEDDED_THEME: &str = include_str!("theme.json");
454
455const EMBEDDED_FONT_SIZES: &str = include_str!("font_sizes.json");
458
459impl Default for Theme {
460 fn default() -> Self {
461 use Base::{
462 B00, B0A, B0B, B0C, B0D, B0E, B0F, B01, B02, B03, B04, B05, B06, B07, B08, B09,
463 };
464 use Role::{
465 Accent0, Accent1, Accent2, Accent3, Accent4, Accent5, Accent6, Accent7, AccentDefault,
466 AlignmentGuide, AreaStroke, AuthorAvatar0, AuthorAvatar1, AuthorAvatar2, AuthorAvatar3,
467 AuthorAvatar4, AuthorAvatarText, CanvasBackground, ChangeRing, ControlHandleFill,
468 ControlHandleStroke, DebugMark, DotReadOnly, DotScratch, DotWriting, DragActiveFill,
469 DragActiveStroke, DragPreviewStroke, EdgeDragPreview, EditorBorder, EditorCaret,
470 EditorFill, EditorHint, EditorSelection, EditorText, GridLine, ImageDragBox, LiveDot,
471 LockedHintIcon, LockedShapeFill, LockedShapeTitle, LockedShapeType, MarqueeFill,
472 MoveRefusedStroke, NewBlockPreviewStroke, NewPinPreviewFill, NewPinPreviewStroke,
473 PinDragIndicator, PinLabelPlaceholder, PinStem, PinStemSelected, PinTag,
474 PinTagSelected, PinText, ResizeCornerActiveFill, ResizeCornerActiveStroke,
475 ResizeCornerFill, ResizeCornerStroke, RouteHighlighted, RouteInProgress, RouteNormal,
476 RouteProposedEndpoint, RouteSelected, RouteStartTarget, SelectionFrame,
477 SelectionFrameOutline, ShapeFill, ShapeStroke, ShapeTitle, ShapeType, SizeReadout,
478 StandingConflict, TagBadge, TagBadgeText, TextBoxFill, TextBoxStroke, TitleBlockBorder,
479 Toast, ToastText, ViewingInk, ViewingReturnInk, ViewingTint, WaypointFill,
480 };
481
482 let mut tones = [Swatch::solid(B05); N_ROLES];
485 let mut set = |role: Role, tone: Swatch| tones[role as usize] = tone;
486
487 set(ShapeFill, Swatch::solid(B01));
489 set(ShapeStroke, Swatch::solid(B0D));
490 set(ShapeTitle, Swatch::solid(B0B));
491 set(LockedShapeFill, Swatch::solid(B02));
492 set(LockedShapeTitle, Swatch::solid(B0A));
493 set(ShapeType, Swatch::solid(B0A));
494 set(LockedShapeType, Swatch::solid(B09));
495 set(LockedHintIcon, Swatch::solid(B0A));
496
497 set(AccentDefault, Swatch::solid(B04));
499 set(Accent0, Swatch::solid(B08));
500 set(Accent1, Swatch::solid(B09));
501 set(Accent2, Swatch::solid(B0A));
502 set(Accent3, Swatch::solid(B0B));
503 set(Accent4, Swatch::solid(B0C));
504 set(Accent5, Swatch::solid(B0D));
505 set(Accent6, Swatch::solid(B0E));
506 set(Accent7, Swatch::solid(B0F));
507
508 set(EditorText, Swatch::solid(B05));
511 set(EditorFill, Swatch::solid(B00));
512 set(EditorCaret, Swatch::solid(B07));
513 set(EditorSelection, Swatch::faded(B0D, 0.4));
514 set(EditorHint, Swatch::solid(B04));
515 set(EditorBorder, Swatch::solid(B07));
516
517 set(TextBoxFill, Swatch::faded(B01, 0.35));
519 set(TextBoxStroke, Swatch::solid(B03));
520
521 set(AreaStroke, Swatch::solid(B06));
524
525 set(RouteNormal, Swatch::solid(B0B));
527 set(RouteSelected, Swatch::solid(B07));
531 set(RouteHighlighted, Swatch::faded(B0B, 0.3));
532 set(RouteInProgress, Swatch::solid(B0A));
533 set(RouteProposedEndpoint, Swatch::solid(B0F));
534
535 set(PinStem, Swatch::solid(B0F));
537 set(PinText, Swatch::solid(B05));
538 set(PinTag, Swatch::solid(B0B));
539 set(PinStemSelected, Swatch::solid(B08));
540 set(PinTagSelected, Swatch::solid(B0C));
541 set(PinLabelPlaceholder, Swatch::solid(B04));
542
543 set(DragPreviewStroke, Swatch::solid(B04));
545 set(DragActiveFill, Swatch::solid(B02));
546 set(DragActiveStroke, Swatch::solid(B0F));
547 set(EdgeDragPreview, Swatch::faded(B04, 0.2));
548 set(MoveRefusedStroke, Swatch::solid(B08));
549 set(StandingConflict, Swatch::faded(B0A, 0.55));
550
551 set(SelectionFrame, Swatch::solid(B0F));
553 set(SelectionFrameOutline, Swatch::solid(B0D));
554 set(ControlHandleFill, Swatch::solid(B07));
555 set(ControlHandleStroke, Swatch::solid(B00));
556 set(WaypointFill, Swatch::faded(B0B, 0.5));
557 set(ResizeCornerFill, Swatch::solid(B0B));
558 set(ResizeCornerStroke, Swatch::solid(B0B));
559 set(ResizeCornerActiveFill, Swatch::solid(B0C));
560 set(ResizeCornerActiveStroke, Swatch::solid(B07));
561 set(ImageDragBox, Swatch::solid(B0D));
562 set(MarqueeFill, Swatch::faded(B0F, 0.1));
563
564 set(NewBlockPreviewStroke, Swatch::solid(B0D));
566 set(NewPinPreviewFill, Swatch::solid(B0D));
567 set(NewPinPreviewStroke, Swatch::solid(B07));
568 set(RouteStartTarget, Swatch::solid(B0B));
569
570 set(CanvasBackground, Swatch::solid(B00));
572 set(GridLine, Swatch::faded(B02, 0.6));
573 set(PinDragIndicator, Swatch::faded(B04, 0.3));
574
575 set(DebugMark, Swatch::faded(B08, 0.6));
577
578 set(AlignmentGuide, Swatch::solid(B07));
581
582 set(SizeReadout, Swatch::solid(B07));
585
586 set(LiveDot, Swatch::solid(B0B));
592 set(DotWriting, Swatch::solid(B0A));
593 set(DotReadOnly, Swatch::solid(B08));
594 set(DotScratch, Swatch::solid(B04));
595 set(ViewingTint, Swatch::faded(B09, 0.28));
596 set(ViewingInk, Swatch::solid(B09));
597 set(ViewingReturnInk, Swatch::solid(B00));
598
599 set(Toast, Swatch::solid(B07));
603 set(ToastText, Swatch::solid(B00));
604
605 set(TitleBlockBorder, Swatch::solid(B04));
609
610 set(ChangeRing, Swatch::solid(B07));
613
614 set(TagBadge, Swatch::solid(B05));
617 set(TagBadgeText, Swatch::solid(B00));
618
619 set(AuthorAvatar0, Swatch::solid(B0D));
623 set(AuthorAvatar1, Swatch::solid(B0B));
624 set(AuthorAvatar2, Swatch::solid(B09));
625 set(AuthorAvatar3, Swatch::solid(B0E));
626 set(AuthorAvatar4, Swatch::solid(B0C));
627 set(AuthorAvatarText, Swatch::solid(B00));
628
629 let fs = FontSizes::default();
630 Self {
631 title_font: Font::canvas(fs.title),
632 type_font: Font::canvas(fs.block_type),
633 pin_font: Font::canvas(fs.pin),
634 pin_subtitle_font: Font::canvas(fs.pin_subtitle),
635 tag_font: Font::canvas(fs.tag),
636 route_font: Font::canvas(fs.route),
637 font_sizes: fs,
638 palette: Palette::tokyo_night_moon(),
639 tones,
640 }
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
652 fn the_embedded_theme_and_font_sizes_parse() {
653 let overrides = embedded_overrides().expect("theme.json names only roles that exist");
654 assert!(
655 !overrides.is_empty(),
656 "precondition: the file overrides something"
657 );
658 embedded_font_sizes().expect("font_sizes.json parses");
659 }
660
661 #[test]
664 fn the_embedded_theme_is_what_the_app_draws_with() {
665 let overrides = embedded_overrides().expect("theme.json parses");
666 let Some(Some(route)) = overrides.get(&Role::RouteNormal) else {
667 panic!("precondition: theme.json overrides RouteNormal");
668 };
669 assert_ne!(
670 Some(*route),
671 Theme::default().base_of(Role::RouteNormal),
672 "precondition: the override differs from the default"
673 );
674 assert_eq!(
675 Theme::from_embedded().base_of(Role::RouteNormal),
676 Some(*route)
677 );
678 }
679
680 fn luminance(color: Color) -> f32 {
683 let channel = |v: u8| {
684 let v = f32::from(v) / 255.0;
685 if v <= 0.039_28 {
686 v / 12.92
687 } else {
688 ((v + 0.055) / 1.055).powf(2.4)
689 }
690 };
691 0.2126 * channel(color.r()) + 0.7152 * channel(color.g()) + 0.0722 * channel(color.b())
692 }
693
694 fn contrast(a: Color, b: Color) -> f32 {
695 let (a, b) = (luminance(a), luminance(b));
696 (a.max(b) + 0.05) / (a.min(b) + 0.05)
697 }
698
699 #[test]
701 fn the_tag_badge_reads_in_every_scheme_and_both_luminances() {
702 use crate::{Luminance, Scheme};
703 for scheme in Scheme::ALL {
704 for luminance in [Luminance::Dark, Luminance::Light] {
705 let mut theme = Theme::default();
706 theme.set_palette(scheme.palette(luminance));
707 let ratio = contrast(
708 theme.resolve(Role::TagBadge),
709 theme.resolve(Role::TagBadgeText),
710 );
711 assert!(
712 ratio >= 4.5,
713 "{scheme:?}/{luminance:?}: the tag badge reads at {ratio:.1}:1",
714 );
715 }
716 }
717 }
718
719 #[test]
720 fn locked_and_type_roles_resolve_to_expected_bases() {
721 let theme = Theme::default();
722 assert_eq!(theme.base_of(Role::LockedShapeFill), Some(Base::B02));
723 assert_eq!(theme.base_of(Role::LockedShapeTitle), Some(Base::B0A));
724 assert_eq!(theme.base_of(Role::ShapeType), Some(Base::B0A));
725 assert_eq!(theme.base_of(Role::LockedShapeType), Some(Base::B09));
726 assert_eq!(theme.base_of(Role::LockedHintIcon), Some(Base::B0A));
727 }
728
729 #[test]
730 fn font_sizes_default_matches_grid_constants() {
731 use blockworx_geom::grid;
732 let fs = FontSizes::default();
733 assert_eq!(fs.title, grid::TITLE_TEXT_SIZE);
734 assert_eq!(fs.block_type, grid::BLOCK_TYPE_TEXT_SIZE);
735 assert_eq!(fs.pin, grid::PORT_TEXT_SIZE);
736 assert_eq!(fs.pin_subtitle, grid::PORT_SUBTITLE_TEXT_SIZE);
737 assert_eq!(fs.tag, grid::TAG_TEXT_SIZE);
738 assert_eq!(fs.route, grid::ROUTE_TEXT_SIZE);
739 }
740
741 #[test]
742 fn font_sizes_json_round_trip() {
743 let fs = FontSizes::default();
744 let json = serde_json::to_string(&fs).unwrap();
745 let back: FontSizes = serde_json::from_str(&json).unwrap();
746 assert_eq!(fs, back);
747 }
748
749 #[test]
750 fn font_sizes_partial_json_uses_defaults() {
751 let fs: FontSizes = serde_json::from_str(r#"{"title":20.0}"#).unwrap();
752 let default = FontSizes::default();
753 assert_eq!(fs.title, 20.0);
754 assert_eq!(fs.block_type, default.block_type);
755 assert_eq!(fs.pin, default.pin);
756 assert_eq!(fs.pin_subtitle, default.pin_subtitle);
757 assert_eq!(fs.tag, default.tag);
758 assert_eq!(fs.route, default.route);
759 }
760
761 #[test]
762 fn set_font_sizes_rebuilds_font_ids() {
763 let mut theme = Theme::default();
764 let custom = FontSizes {
765 title: 21.0,
766 block_type: 18.0,
767 pin: 17.0,
768 pin_subtitle: 10.0,
769 tag: 13.0,
770 route: 14.0,
771 };
772 theme.set_font_sizes(custom);
773 assert_eq!(theme.title_font.size, custom.title);
774 assert_eq!(theme.type_font.size, custom.block_type);
775 assert_eq!(theme.pin_font.size, custom.pin);
776 assert_eq!(theme.pin_subtitle_font.size, custom.pin_subtitle);
777 assert_eq!(theme.tag_font.size, custom.tag);
778 assert_eq!(theme.route_font.size, custom.route);
779 assert_eq!(theme.font_sizes(), custom);
780 }
781
782 #[test]
783 fn embedded_font_sizes_parses() {
784 assert!(serde_json::from_str::<FontSizes>(EMBEDDED_FONT_SIZES).is_ok());
785 }
786}