Skip to main content

blockworx/theme/
role_picker.rs

1//! The accent-color (role) picker popup: a 3×3 grid of palette swatches shown
2//! above the selection overlay for the selected shape (block, port, route,
3//! area, or text box). The top-left cell is the "no role" default
4//! ([`Role::AccentDefault`], or the target's own un-accented stroke); the other
5//! eight are accent roles `0..7` ([`Role::Accent0`]..[`Role::Accent7`]). The
6//! swatch matching the shape's current role carries a thick active-indicator
7//! ring (palette `B07`). Clicking a swatch sets the role; clicking outside
8//! dismisses the popup.
9
10use crate::canvas::egui_compat::IntoEgui as _;
11use egui::{Pos2, Sense, Stroke, StrokeKind, vec2};
12
13use crate::{
14    canvas::palette::Base,
15    theme::{Role, Theme},
16};
17
18/// Outcome of showing the picker for one frame.
19pub enum RolePick {
20    /// A swatch was clicked: set the shape's role to this value (`None` = default).
21    Set(Option<u8>),
22    /// The user clicked outside the popup: dismiss it.
23    Dismiss,
24    /// Nothing happened this frame.
25    None,
26}
27
28/// The nine cells in row-major order: the role value each sets and the accent
29/// [`Role`] it displays.
30const CELLS: [(Option<u8>, Role); 9] = [
31    (None, Role::AccentDefault),
32    (Some(0), Role::Accent0),
33    (Some(1), Role::Accent1),
34    (Some(2), Role::Accent2),
35    (Some(3), Role::Accent3),
36    (Some(4), Role::Accent4),
37    (Some(5), Role::Accent5),
38    (Some(6), Role::Accent6),
39    (Some(7), Role::Accent7),
40];
41
42/// Side length (points) of each swatch.
43const SWATCH: f32 = 28.0;
44
45/// Show the picker with its bottom-right corner anchored at screen position `at`
46/// (just above the selection overlay), highlighting `current`. `default_role`
47/// colors the top-left "no accent" cell, so it can match the target's own
48/// un-accented stroke ([`Role::AreaStroke`] for an area,
49/// [`Role::TextBoxStroke`] for a text box, [`Role::AccentDefault`] otherwise).
50/// Must be driven from app state that was set on a *previous* frame, so the
51/// click that opened the popup isn't read here as a click-outside dismiss.
52pub fn show(
53    ctx: &egui::Context,
54    at: Pos2,
55    theme: &Theme,
56    current: Option<u8>,
57    default_role: Role,
58) -> RolePick {
59    let mut pick = RolePick::None;
60    // Both rings come from the palette, not from a fixed grey: the cells
61    // they surround are palette colors, so a scheme change has to move the
62    // rings with them or one of the two stops reading (R18).
63    let active_ring = theme.palette().get(Base::B07).egui();
64    let resting_ring = theme.palette().get(Base::B03).egui();
65    let area = egui::Area::new(egui::Id::new("role_picker"))
66        .order(egui::Order::Foreground)
67        .fixed_pos(at)
68        .pivot(egui::Align2::RIGHT_BOTTOM)
69        .show(ctx, |ui| {
70            egui::Frame::popup(ui.style()).show(ui, |ui| {
71                egui::Grid::new("role_picker_grid")
72                    .spacing(vec2(4.0, 4.0))
73                    .show(ui, |ui| {
74                        for (i, (value, role)) in CELLS.iter().enumerate() {
75                            let (rect, resp) =
76                                ui.allocate_exact_size(vec2(SWATCH, SWATCH), Sense::click());
77                            // The "no accent" cell previews the target's own default
78                            // stroke rather than the fixed `AccentDefault`.
79                            let role = if value.is_none() { default_role } else { *role };
80                            let p = ui.painter();
81                            p.rect_filled(rect, 4.0, theme.resolve(role).egui());
82                            let (w, col) = if *value == current {
83                                (3.0, active_ring)
84                            } else {
85                                (1.0, resting_ring)
86                            };
87                            p.rect_stroke(rect, 4.0, Stroke::new(w, col), StrokeKind::Inside);
88                            if resp.clicked() {
89                                pick = RolePick::Set(*value);
90                            }
91                            if (i + 1) % 3 == 0 {
92                                ui.end_row();
93                            }
94                        }
95                    });
96            });
97        });
98    // A swatch click takes precedence; otherwise a click outside dismisses.
99    if matches!(pick, RolePick::None) && area.response.clicked_elsewhere() {
100        pick = RolePick::Dismiss;
101    }
102    pick
103}