Skip to main content

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}