Skip to main content

frus_widgets/
colorpicker.rs

1//! [`ColorPicker`]: a palette of clickable colour swatches, built on
2//! [`crate::GridView`]. The selected swatch carries a ring.
3
4use frus_core::{Color, Rect, Scene};
5use frus_layout::{Dimension, Style};
6
7use crate::grid::GridView;
8use crate::interaction::Status;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12const SWATCH: f32 = 30.0;
13
14/// A clickable color swatch.
15struct Swatch<Msg> {
16    color: Color,
17    selected: bool,
18    message: Msg,
19}
20
21impl<Msg: Clone> Widget<Msg> for Swatch<Msg> {
22    fn style(&self) -> Style {
23        Style {
24            width: Dimension::Length(SWATCH),
25            height: Dimension::Length(SWATCH),
26            ..Default::default()
27        }
28    }
29
30    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
31        &[]
32    }
33
34    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
35        let o = status.opacity;
36        scene.draw_rect(bounds, self.color.fade(o), 8.0, 0.0, Color::TRANSPARENT);
37        if self.selected {
38            // The selection ring, overflowing slightly.
39            let ring = Rect::new(
40                bounds.x - 2.0,
41                bounds.y - 2.0,
42                bounds.width + 4.0,
43                bounds.height + 4.0,
44            );
45            scene.draw_rect(ring, Color::TRANSPARENT, 10.0, 2.0, theme.focus.fade(o));
46        }
47    }
48
49    fn on_click(&self) -> Option<Msg> {
50        Some(self.message.clone())
51    }
52
53    fn focusable(&self) -> bool {
54        true
55    }
56}
57
58/// A controlled colour picker, laid out as a grid of swatches.
59pub struct ColorPicker<Msg = crate::callback::Callback> {
60    selected: Option<Color>,
61    on_pick: Box<dyn Fn(Color) -> Msg>,
62    grid: GridView<Msg>,
63}
64
65impl<Msg: Clone + 'static> ColorPicker<Msg> {
66    /// Creates a picker: the selected colour if there is one, `on_pick(colour)` for
67    /// clicks, and a column count.
68    pub fn new(
69        selected: Option<Color>,
70        columns: usize,
71        on_pick: impl Fn(Color) -> Msg + 'static,
72    ) -> Self {
73        Self {
74            selected,
75            on_pick: Box::new(on_pick),
76            grid: GridView::new(columns.max(1)).gap(8.0),
77        }
78    }
79
80    /// Adds a color swatch.
81    pub fn swatch(mut self, color: Color) -> Self {
82        let selected = self.selected == Some(color);
83        let message = (self.on_pick)(color);
84        self.grid = self.grid.cell(Swatch {
85            color,
86            selected,
87            message,
88        });
89        self
90    }
91}
92
93// `Msg: 'static` follows the grid this picker *is*: a `GridView` can build its cells late
94// (`GridView::extent`), and a closure that hands back a boxed widget needs the message type
95// to outlive it. Every application's message enum does.
96impl<Msg: Clone + 'static> Widget<Msg> for ColorPicker<Msg> {
97    fn style(&self) -> Style {
98        Widget::<Msg>::style(&self.grid)
99    }
100
101    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
102        Widget::<Msg>::children(&self.grid)
103    }
104
105    // The picker *is* its grid, so it answers for it: a hook the grid resolves during
106    // the walk is lost if only `style` and `children` are forwarded.
107    fn tile_shape(&self) -> Option<f32> {
108        Widget::<Msg>::tile_shape(&self.grid)
109    }
110
111    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
112
113    fn on_click(&self) -> Option<Msg> {
114        None
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::{build_ui, Runtime, Size};
122    use frus_core::Primitive;
123
124    #[derive(Clone, Debug, PartialEq)]
125    enum Msg {
126        Pick(Color),
127    }
128
129    #[test]
130    fn swatches_pick_and_selected_has_ring() {
131        let red = Color::rgb(1.0, 0.0, 0.0);
132        let blue = Color::rgb(0.0, 0.0, 1.0);
133        let picker = ColorPicker::new(Some(red), 4, Msg::Pick)
134            .swatch(red)
135            .swatch(blue);
136        assert_eq!(Widget::<Msg>::children(&picker).len(), 2);
137
138        let ui = build_ui(
139            &picker,
140            Size::new(200.0, 60.0),
141            &Runtime::default(),
142            &Theme::default(),
143        );
144        // The selection, red here, adds a ring, drawn as a focus border.
145        let focus = Theme::default().focus;
146        let has_ring = ui.scene().primitives().iter().any(|p| {
147            matches!(p, Primitive::Rect { border_color, border_width, .. } if *border_width > 0.0 && *border_color == focus)
148        });
149        assert!(has_ring, "the selected swatch has a ring");
150    }
151}