Skip to main content

teksilo_widgets/color_picker/
swatch_grid.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `SwatchGrid` — Role::Grid container of color swatches with arrow-key
5//! roving focus, mirroring the Calendar widget's grid pattern.
6//!
7//! Lays out its children in fixed-column rows using the existing
8//! [`Grid`] primitive. Click / Enter / Space
9//! on any cell calls `on_select(color, ctx)`. Tab moves focus into the
10//! first cell; arrow keys move within the grid (Left/Right by 1,
11//! Up/Down by `columns`, Home/End to row bounds, Ctrl+Home/End to grid
12//! bounds); Tab again leaves the grid.
13
14use std::rc::Rc;
15
16use teksilo_canvas::{Rect, SizeProposal};
17use teksilo_core::accessibility::AccessNodeBuilder;
18use teksilo_core::accesskit::Role;
19use teksilo_core::build_context::BuildContext;
20use teksilo_core::event::{EventResponse, Key, WidgetEvent};
21use teksilo_core::signal::Signal;
22use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
23use teksilo_core::widget_builder::HandlerSet;
24use teksilo_core::widget_id::WidgetId;
25use teksilo_i18n::resolve_message_widget;
26use teksilo_tokens::Color;
27
28use super::swatch::ColorSwatch;
29use crate::primitives::{Grid, TrackSize};
30
31pub(crate) struct SwatchGrid {
32    swatches: Signal<Vec<Color>>,
33    selected: Signal<Color>,
34    columns: usize,
35    on_select: Rc<dyn Fn(Color, &mut EventContext)>,
36    /// Currently focused cell index inside the grid. Used for the
37    /// roving-focus pattern (only one cell takes focus; arrow keys
38    /// move between cells).
39    focused_index: Signal<usize>,
40    /// Initial enabled-state; forwarded to the arena at build time.
41    initial_enabled: bool,
42    root_child_id: Option<WidgetId>,
43}
44
45impl SwatchGrid {
46    pub(crate) fn new(
47        swatches: Signal<Vec<Color>>,
48        selected: Signal<Color>,
49        columns: usize,
50        on_select: Rc<dyn Fn(Color, &mut EventContext)>,
51    ) -> Self {
52        Self {
53            swatches,
54            selected,
55            columns: columns.max(1),
56            on_select,
57            focused_index: Signal::new(0),
58            initial_enabled: true,
59            root_child_id: None,
60        }
61    }
62
63    /// Set the initial enabled state. Forwarded to the arena at build time.
64    pub(crate) fn enabled(mut self, enabled: bool) -> Self {
65        self.initial_enabled = enabled;
66        self
67    }
68}
69
70impl std::fmt::Debug for SwatchGrid {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.debug_struct("SwatchGrid")
73            .field("columns", &self.columns)
74            .finish_non_exhaustive()
75    }
76}
77
78impl Widget for SwatchGrid {
79    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
80        let self_id = ctx.self_id();
81        // Forward initial-enabled into the arena; see IconButton.
82        if !self.initial_enabled {
83            ctx.enabled_when(self_id, false);
84        }
85        let registry = ctx.binding_registry();
86        // Re-layout when the swatches list changes; repaint when
87        // selection moves (children re-render with the new ring).
88        self.swatches.bind_to(
89            self_id,
90            registry,
91            teksilo_core::binding::BindingLevel::Relayout,
92        );
93        self.selected.bind_to(
94            self_id,
95            registry,
96            teksilo_core::binding::BindingLevel::RepaintOnly,
97        );
98
99        let swatches = self.swatches.get();
100        let columns = self.columns;
101        use crate::styles::recipe_color_picker_style as cp;
102
103        // Build one ColorSwatch per color.
104        let on_select = self.on_select.clone();
105        let selected = self.selected.clone();
106        let mut grid = Grid::new()
107            .columns(vec![TrackSize::Auto; columns])
108            .row_gap(cp::SWATCH_SPACING)
109            .column_gap(cp::SWATCH_SPACING);
110        for color in &swatches {
111            let color = *color;
112            let on_select = on_select.clone();
113            let is_selected = selected.get() == color;
114            let cell = ColorSwatch::new(color)
115                .selected(is_selected)
116                .enabled(self.initial_enabled)
117                .on_activate_fn(move |ctx_evt| {
118                    (on_select)(color, ctx_evt);
119                });
120            grid = grid.child(cell);
121        }
122        let root = ctx.add(grid);
123        self.root_child_id = Some(root);
124
125        // Self handlers — Tab brings focus in, arrow keys move
126        // focused_index across the grid. Roving focus pattern.
127        let count = swatches.len();
128        let columns_for_keys = self.columns;
129        let focused_index = self.focused_index.clone();
130        let on_select_keys = self.on_select.clone();
131        let swatches_for_keys = self.swatches.clone();
132        let handlers = HandlerSet::new()
133            // Framework gates events on `arena.is_enabled` and
134            // the focus walker skips disabled subtrees; we still
135            // refuse to focus an empty grid.
136            .focusable(count > 0)
137            .on_key(move |event, ctx_evt| {
138                if count == 0 {
139                    return EventResponse::Ignored;
140                }
141                let WidgetEvent::KeyDown { key, modifiers, .. } = event else {
142                    return EventResponse::Ignored;
143                };
144                let mut idx = focused_index.get().min(count.saturating_sub(1));
145                let last = count.saturating_sub(1);
146                let row = idx / columns_for_keys;
147                let col = idx % columns_for_keys;
148                let row_start = row * columns_for_keys;
149                let row_end = ((row + 1) * columns_for_keys - 1).min(last);
150                match key {
151                    Key::ArrowLeft => {
152                        idx = idx.saturating_sub(1);
153                    }
154                    Key::ArrowRight => {
155                        if idx < last {
156                            idx += 1;
157                        }
158                    }
159                    Key::ArrowUp => {
160                        if idx >= columns_for_keys {
161                            idx -= columns_for_keys;
162                        }
163                    }
164                    Key::ArrowDown => {
165                        if idx + columns_for_keys <= last {
166                            idx += columns_for_keys;
167                        }
168                    }
169                    // The accelerator (Ctrl, ⌘ on macOS) widens Home / End
170                    // from the current row to the whole grid.
171                    Key::Home => {
172                        idx = if modifiers.command() { 0 } else { row_start };
173                    }
174                    Key::End => {
175                        idx = if modifiers.command() { last } else { row_end };
176                    }
177                    Key::Enter | Key::Space => {
178                        let list = swatches_for_keys.get();
179                        if let Some(c) = list.get(idx) {
180                            (on_select_keys)(*c, ctx_evt);
181                        }
182                        return EventResponse::Handled;
183                    }
184                    _ => return EventResponse::Ignored,
185                }
186                let _ = (row, col);
187                focused_index.set(idx);
188                EventResponse::Handled
189            });
190        ctx.apply_self_handlers(handlers);
191
192        vec![root]
193    }
194
195    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
196        match self.root_child_id {
197            Some(id) => ctx
198                .child_layout_response(id, proposal)
199                .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into()),
200            None => proposal.resolve(0.0, 0.0).into(),
201        }
202    }
203
204    fn place_children(
205        &self,
206        bounds: Rect,
207        _proposal: SizeProposal,
208        children: &mut [WidgetPlacement],
209        _ctx: &LayoutContext,
210    ) {
211        for child in children.iter_mut() {
212            child.origin = bounds.origin();
213            child.size = bounds.size();
214        }
215    }
216
217    fn children(&self) -> Vec<WidgetId> {
218        self.root_child_id.into_iter().collect()
219    }
220
221    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
222        builder.set_role(Role::Grid);
223        builder.set_name(resolve_message_widget("color-picker-swatches-name", &[]));
224        // Framework a11y walker sets `set_disabled` from arena state.
225    }
226}