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#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, strum::EnumIter)]
21pub enum Role {
22 ShapeFill,
24 ShapeStroke,
25 ShapeTitle,
26 LockedShapeFill,
29 LockedShapeTitle,
30 ShapeType,
31 LockedShapeType,
32 LockedHintIcon,
33
34 AccentDefault,
37 Accent0,
38 Accent1,
39 Accent2,
40 Accent3,
41 Accent4,
42 Accent5,
43 Accent6,
44 Accent7,
45
46 TextBoxFill,
48 TextBoxStroke,
49
50 AreaStroke,
52
53 RouteNormal,
55 RouteSelected,
56 RouteHighlighted,
57 RouteInProgress,
58 RouteProposedEndpoint,
59
60 PinStem,
62 PinText,
63 PinTag,
64 PinStemSelected,
65 PinTagSelected,
66 PinLabelPlaceholder,
69
70 DragPreviewStroke,
72 DragActiveFill,
73 DragActiveStroke,
74 EdgeDragPreview,
75
76 SelectionFrame,
78 SelectionFrameOutline,
79 ControlHandleFill,
80 ControlHandleStroke,
81 WaypointFill,
82 ResizeCornerFill,
83 ResizeCornerStroke,
84 ResizeCornerActiveFill,
85 ResizeCornerActiveStroke,
86 ImageDragBox,
87 MarqueeFill,
88
89 NewBlockPreviewStroke,
91 NewPinPreviewFill,
92 NewPinPreviewStroke,
93 RouteStartTarget,
96
97 CanvasBackground,
99 GridLine,
100 PinDragIndicator,
101
102 DebugTextBbox,
104 DebugMark,
105
106 AlignmentGuide,
109
110 SizeReadout,
113
114 LiveDot,
120 DotWriting,
121 DotReadOnly,
122 DotScratch,
123
124 ViewingTint,
130 ViewingInk,
131 ViewingReturnInk,
132
133 Toast,
140 ToastText,
141
142 TagBadge,
149 TagBadgeText,
150
151 AuthorAvatar0,
159 AuthorAvatar1,
160 AuthorAvatar2,
161 AuthorAvatar3,
162 AuthorAvatar4,
163 AuthorAvatarText,
165
166 TitleBlockBorder,
172
173 ChangeRing,
178
179 Transparent,
182}
183
184pub const N_ROLES: usize = Role::Transparent as usize + 1;
186
187pub 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#[derive(Clone, Copy)]
213pub struct RoleStroke {
214 pub width: WorldPx,
215 pub role: Role,
216}
217
218impl RoleStroke {
219 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#[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 pub title_font: Font,
269 pub type_font: Font,
271 pub pin_font: Font,
272 pub pin_subtitle_font: Font,
274 pub tag_font: Font,
275 pub route_font: Font,
276 font_sizes: FontSizes,
279
280 palette: Palette,
282 tones: [Swatch; N_ROLES],
284}
285
286impl Theme {
287 pub fn resolve(&self, role: Role) -> Color {
293 self.palette.resolve(self.swatch(role))
294 }
295
296 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 pub fn palette(&self) -> &Palette {
309 &self.palette
310 }
311
312 pub fn set_palette(&mut self, palette: Palette) {
316 self.palette = palette;
317 }
318
319 pub fn base_of(&self, role: Role) -> Option<Base> {
322 self.tones[role as usize].base
323 }
324
325 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 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 pub fn font_sizes(&self) -> FontSizes {
345 self.font_sizes
346 }
347
348 pub fn set_font_sizes(&mut self, fs: FontSizes) {
351 self.apply_font_sizes(fs);
352 }
353
354 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 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
388const EMBEDDED_THEME: &str = include_str!("theme.json");
391
392const 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 let mut tones = [Swatch::solid(B05); N_ROLES];
421 let mut set = |role: Role, tone: Swatch| tones[role as usize] = tone;
422
423 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 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 set(TextBoxFill, Swatch::faded(B01, 0.35));
446 set(TextBoxStroke, Swatch::solid(B03));
447
448 set(AreaStroke, Swatch::solid(B06));
451
452 set(RouteNormal, Swatch::solid(B0B));
454 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 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 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 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 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 set(CanvasBackground, Swatch::solid(B00));
497 set(GridLine, Swatch::faded(B02, 0.6));
498 set(PinDragIndicator, Swatch::faded(B04, 0.3));
499
500 set(DebugTextBbox, Swatch::faded(B09, 0.7));
502 set(DebugMark, Swatch::faded(B08, 0.6));
503
504 set(AlignmentGuide, Swatch::solid(B07));
507
508 set(SizeReadout, Swatch::solid(B07));
511
512 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 set(Toast, Swatch::solid(B07));
529 set(ToastText, Swatch::solid(B00));
530
531 set(TitleBlockBorder, Swatch::solid(B04));
535
536 set(ChangeRing, Swatch::solid(B07));
539
540 set(TagBadge, Swatch::solid(B05));
543 set(TagBadgeText, Swatch::solid(B00));
544
545 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 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 #[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}