blockworx/theme_editor.rs
1//! Live theme editor (`--theme-editor`).
2//!
3//! Opens a second OS window (an egui *immediate* viewport) listing every
4//! [`Role`] with a dropdown to pick the palette [`Base`] it resolves through.
5//! Because it is an immediate viewport, its UI runs inside the app's `ui`
6//! and edits the live [`Theme`] in place — the main canvas reflects each change
7//! the same frame. Persistence to `theme.json` is handled by the caller on exit.
8
9use crate::canvas::convert::IntoEgui as _;
10use egui::{Color32, Sense, Stroke, StrokeKind, ViewportId, vec2};
11use strum::IntoEnumIterator;
12
13use crate::theme::{Role, Theme};
14use blockworx_paint::Base;
15
16/// Render the theme-editor window for this frame, mutating `theme` in place as
17/// the user picks bases. Returns `true` once the window's close is requested, so
18/// the caller can stop showing it and persist the result.
19pub fn show(ctx: &egui::Context, theme: &mut Theme) -> bool {
20 let builder = egui::ViewportBuilder::default()
21 .with_title("Theme Editor")
22 .with_inner_size([360.0, 640.0]);
23
24 // The callback renders straight into the viewport's root `Ui`: a real second
25 // OS window when the backend supports viewports, or — on backends without
26 // multi-viewport support — a `Ui` egui has already wrapped in a `Window`
27 // inside the parent (`ViewportClass::EmbeddedWindow`). Either way we just fill
28 // the provided `ui`.
29 ctx.show_viewport_immediate(
30 ViewportId::from_hash_of("theme_editor"),
31 builder,
32 |ui, _class| {
33 table(ui, theme);
34 ui.input(|i| i.viewport().close_requested())
35 },
36 )
37}
38
39/// The scrolling role → base table. One row per [`Role`] (except the synthetic
40/// [`Role::Transparent`], which has no base).
41fn table(ui: &mut egui::Ui, theme: &mut Theme) {
42 ui.heading("Role → Base");
43 ui.label("Changes apply live; the mapping is saved to theme.json on exit.");
44 ui.separator();
45
46 egui::ScrollArea::vertical().show(ui, |ui| {
47 egui::Grid::new("theme_roles_grid")
48 .num_columns(3)
49 .striped(true)
50 .show(ui, |ui| {
51 for role in Role::iter() {
52 if role == Role::Transparent {
53 continue;
54 }
55 ui.label(format!("{role:?}"));
56 swatch(ui, theme.resolve(role).egui());
57
58 let mut base = theme.base_of(role);
59 let before = base;
60 egui::ComboBox::from_id_salt(role)
61 .selected_text(base_label(base))
62 .show_ui(ui, |ui| {
63 // "None" maps the role to no color (transparent).
64 ui.horizontal(|ui| {
65 swatch(ui, Color32::TRANSPARENT);
66 ui.selectable_value(&mut base, None, base_label(None));
67 });
68 for b in Base::iter() {
69 ui.horizontal(|ui| {
70 swatch(ui, theme.palette().get(b).egui());
71 ui.selectable_value(&mut base, Some(b), base_label(Some(b)));
72 });
73 }
74 });
75 if base != before {
76 theme.set_base(role, base);
77 // The edited theme is injected into the root viewport on
78 // its next frame; nudge it to repaint so the canvas
79 // updates without needing a mouse move there.
80 ui.ctx().request_repaint_of(ViewportId::ROOT);
81 }
82 ui.end_row();
83 }
84 });
85 });
86}
87
88/// The dropdown/`selected_text` label for a base slot: the variant name
89/// (`"B01"`) or `"None"` for a role that draws nothing.
90fn base_label(base: Option<Base>) -> String {
91 match base {
92 Some(b) => format!("{b:?}"),
93 None => "None".to_owned(),
94 }
95}
96
97/// A small filled-color chip with a thin border, used both for a role's current
98/// resolved color and beside each base option in the dropdown.
99///
100/// The border is egui's own non-interactive outline rather than a fixed grey:
101/// this window is chrome, so it follows the toolkit's visuals like every
102/// other frame in it, and a chip on a light theme keeps a visible edge.
103fn swatch(ui: &mut egui::Ui, color: Color32) {
104 let (rect, _) = ui.allocate_exact_size(vec2(20.0, 14.0), Sense::hover());
105 let edge = ui.visuals().widgets.noninteractive.fg_stroke.color;
106 let painter = ui.painter();
107 painter.rect_filled(rect, 2.0, color);
108 painter.rect_stroke(rect, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside);
109}