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}