freya_components/
color_picker.rs

1use freya_animation::{
2    easing::Function,
3    hook::{
4        AnimatedValue,
5        Ease,
6        OnChange,
7        OnCreation,
8        ReadAnimatedValue,
9        use_animation,
10    },
11    prelude::AnimNum,
12};
13use freya_core::prelude::*;
14use freya_edit::Clipboard;
15use torin::prelude::{
16    Alignment,
17    Area,
18    CursorPoint,
19    Position,
20    Size,
21};
22
23use crate::{
24    button::Button,
25    context_menu::ContextMenu,
26    get_theme,
27    menu::{
28        Menu,
29        MenuButton,
30    },
31    theming::component_themes::ColorPickerThemePartial,
32};
33
34/// HSV-based gradient color picker.
35///
36/// ## Example
37///
38/// ```rust
39/// # use freya::prelude::*;
40/// fn app() -> impl IntoElement {
41///     let mut color = use_state(|| Color::from_hsv(0.0, 1.0, 1.0));
42///     rect()
43///         .padding(6.)
44///         .child(ColorPicker::new(move |c| color.set(c)).value(color()))
45/// }
46/// # use freya_testing::prelude::*;
47/// # use std::time::Duration;
48/// # launch_doc(|| {
49/// #     rect().padding(6.).child(app())
50/// # }, "./images/gallery_color_picker.png").with_hook(|t| { t.move_cursor((15., 15.)); t.click_cursor((15., 15.)); t.poll(Duration::from_millis(1), Duration::from_millis(250)); }).with_scale_factor(0.85).render();
51/// ```
52///
53/// # Preview
54/// ![ColorPicker Preview][gallery_color_picker]
55#[cfg_attr(feature = "docs",
56    doc = embed_doc_image::embed_image!("gallery_color_picker", "images/gallery_color_picker.png"),
57)]
58///
59/// The preview image is generated by simulating a click on the preview so the popup is shown.
60/// This is done using the `with_hook` helper in the doc test to move the cursor and click the preview.
61#[derive(Clone, PartialEq)]
62pub struct ColorPicker {
63    pub(crate) theme: Option<ColorPickerThemePartial>,
64    value: Color,
65    on_change: EventHandler<Color>,
66    width: Size,
67    key: DiffKey,
68}
69
70impl KeyExt for ColorPicker {
71    fn write_key(&mut self) -> &mut DiffKey {
72        &mut self.key
73    }
74}
75
76impl ColorPicker {
77    pub fn new(on_change: impl Into<EventHandler<Color>>) -> Self {
78        Self {
79            theme: None,
80            value: Color::WHITE,
81            on_change: on_change.into(),
82            width: Size::px(220.),
83            key: DiffKey::None,
84        }
85    }
86
87    pub fn value(mut self, value: Color) -> Self {
88        self.value = value;
89        self
90    }
91
92    pub fn width(mut self, width: impl Into<Size>) -> Self {
93        self.width = width.into();
94        self
95    }
96}
97
98/// Which part of the color picker is being dragged, if any.
99#[derive(Clone, Copy, PartialEq, Default)]
100enum DragTarget {
101    #[default]
102    None,
103    Sv,
104    Hue,
105}
106
107impl Component for ColorPicker {
108    fn render(&self) -> impl IntoElement {
109        let mut open = use_state(|| false);
110        let mut color = use_state(|| self.value);
111        let mut dragging = use_state(DragTarget::default);
112        let mut area = use_state(Area::default);
113        let mut hue_area = use_state(Area::default);
114
115        let is_open = open();
116
117        let preview = rect()
118            .width(Size::px(40.))
119            .height(Size::px(24.))
120            .corner_radius(4.)
121            .background(self.value)
122            .on_press(move |_| {
123                open.toggle();
124            });
125
126        let theme = get_theme!(&self.theme, color_picker);
127        let hue_bar = rect()
128            .height(Size::px(18.))
129            .width(Size::fill())
130            .corner_radius(4.)
131            .on_sized(move |e: Event<SizedEventData>| hue_area.set(e.area))
132            .background_linear_gradient(
133                LinearGradient::new()
134                    .angle(-90.)
135                    .stop(((255, 0, 0), 0.))
136                    .stop(((255, 255, 0), 16.))
137                    .stop(((0, 255, 0), 33.))
138                    .stop(((0, 255, 255), 50.))
139                    .stop(((0, 0, 255), 66.))
140                    .stop(((255, 0, 255), 83.))
141                    .stop(((255, 0, 0), 100.)),
142            );
143
144        let sv_area = rect()
145            .height(Size::px(140.))
146            .width(Size::fill())
147            .corner_radius(4.)
148            .overflow(Overflow::Clip)
149            .child(
150                rect()
151                    .expanded()
152                    .background_linear_gradient(
153                        // left: white -> right: hue color
154                        LinearGradient::new()
155                            .angle(-90.)
156                            .stop(((255, 255, 255), 0.))
157                            .stop((Color::from_hsv(color.read().to_hsv().h, 1.0, 1.0), 100.)),
158                    )
159                    .child(
160                        rect()
161                            .position(Position::new_absolute())
162                            .expanded()
163                            .background_linear_gradient(
164                                // top: transparent -> bottom: black
165                                LinearGradient::new()
166                                    .stop(((255, 255, 255, 0.0), 0.))
167                                    .stop(((0, 0, 0), 100.)),
168                            ),
169                    ),
170            );
171
172        // Minimum perceptible floor to avoid full desaturation/black when dragging
173        const MIN_S: f32 = 0.07;
174        const MIN_V: f32 = 0.07;
175
176        let mut update_sv = {
177            let on_change = self.on_change.clone();
178            move |coords: CursorPoint| {
179                let sv_area = area.read().to_f64();
180                let rel_x = (((coords.x - sv_area.min_x()) / sv_area.width()).clamp(0., 1.)) as f32;
181                let rel_y = (((coords.y - sv_area.min_y()) / sv_area.height())
182                    .clamp(MIN_V as f64, 1. - MIN_V as f64)) as f32;
183                let sat = rel_x.max(MIN_S);
184                let v = (1.0 - rel_y).clamp(MIN_V, 1.0 - MIN_V);
185                let hsv = color.read().to_hsv();
186                color.set(Color::from_hsv(hsv.h, sat, v));
187                on_change.call(color());
188            }
189        };
190
191        let mut update_hue = {
192            let on_change = self.on_change.clone();
193            move |coords: CursorPoint| {
194                let bar_area = hue_area.read().to_f64();
195                let rel_x =
196                    ((coords.x - bar_area.min_x()) / bar_area.width()).clamp(0.01, 1.) as f32;
197                let hsv = color.read().to_hsv();
198                color.set(Color::from_hsv(rel_x * 360.0, hsv.s, hsv.v));
199                on_change.call(color());
200            }
201        };
202
203        let on_sv_pointer_down = {
204            let mut update_sv = update_sv.clone();
205            move |e: Event<PointerEventData>| {
206                dragging.set(DragTarget::Sv);
207                update_sv(e.global_location());
208            }
209        };
210
211        let on_hue_pointer_down = {
212            let mut update_hue = update_hue.clone();
213            move |e: Event<PointerEventData>| {
214                dragging.set(DragTarget::Hue);
215                update_hue(e.global_location());
216            }
217        };
218
219        let on_global_pointer_move = move |e: Event<PointerEventData>| match *dragging.read() {
220            DragTarget::Sv => update_sv(e.global_location()),
221            DragTarget::Hue => update_hue(e.global_location()),
222            DragTarget::None => {}
223        };
224
225        let on_global_pointer_press = move |_| {
226            // Only close the popup if it wasnt being dragged and it is open
227            if is_open && dragging() == DragTarget::None {
228                open.set(false);
229            }
230            dragging.set_if_modified(DragTarget::None);
231        };
232
233        let animation = use_animation(move |conf| {
234            conf.on_change(OnChange::Rerun);
235            conf.on_creation(OnCreation::Finish);
236
237            let scale = AnimNum::new(0.8, 1.)
238                .time(200)
239                .ease(Ease::Out)
240                .function(Function::Expo);
241            let opacity = AnimNum::new(0., 1.)
242                .time(200)
243                .ease(Ease::Out)
244                .function(Function::Expo);
245
246            if open() {
247                (scale, opacity)
248            } else {
249                (scale, opacity).into_reversed()
250            }
251        });
252
253        let (scale, opacity) = animation.read().value();
254
255        let popup = rect()
256            .on_global_pointer_move(on_global_pointer_move)
257            .on_global_pointer_press(on_global_pointer_press)
258            .width(self.width.clone())
259            .padding(8.)
260            .corner_radius(6.)
261            .background(theme.background)
262            .border(
263                Border::new()
264                    .fill(theme.border_fill)
265                    .width(1.)
266                    .alignment(BorderAlignment::Inner),
267            )
268            .color(theme.color)
269            .spacing(8.)
270            .shadow(Shadow::new().x(0.).y(2.).blur(8.).color((0, 0, 0, 0.1)))
271            .child(
272                rect()
273                    .on_sized(move |e: Event<SizedEventData>| area.set(e.area))
274                    .on_pointer_down(on_sv_pointer_down)
275                    .child(sv_area),
276            )
277            .child(
278                rect()
279                    .height(Size::px(18.))
280                    .on_pointer_down(on_hue_pointer_down)
281                    .child(hue_bar),
282            )
283            .child({
284                let hex = format!(
285                    "#{:02X}{:02X}{:02X}",
286                    color.read().r(),
287                    color.read().g(),
288                    color.read().b()
289                );
290
291                rect()
292                    .horizontal()
293                    .width(Size::fill())
294                    .main_align(Alignment::center())
295                    .spacing(8.)
296                    .child(
297                        Button::new()
298                            .on_press(move |e: Event<PressEventData>| {
299                                e.stop_propagation();
300                                e.prevent_default();
301                                if ContextMenu::is_open() {
302                                    ContextMenu::close();
303                                } else {
304                                    ContextMenu::open(
305                                        Menu::new()
306                                            .child(
307                                                MenuButton::new()
308                                                    .on_press(move |e: Event<PressEventData>| {
309                                                        e.stop_propagation();
310                                                        e.prevent_default();
311                                                        ContextMenu::close();
312                                                        let _ =
313                                                            Clipboard::set(color().to_rgb_string());
314                                                    })
315                                                    .child("Copy as RGB"),
316                                            )
317                                            .child(
318                                                MenuButton::new()
319                                                    .on_press(move |e: Event<PressEventData>| {
320                                                        e.stop_propagation();
321                                                        e.prevent_default();
322                                                        ContextMenu::close();
323                                                        let _ =
324                                                            Clipboard::set(color().to_hex_string());
325                                                    })
326                                                    .child("Copy as HEX"),
327                                            ),
328                                    )
329                                }
330                            })
331                            .compact()
332                            .child(hex),
333                    )
334            });
335
336        rect().horizontal().spacing(8.).child(preview).child(
337            rect()
338                .width(Size::px(0.))
339                .height(Size::px(0.))
340                .opacity(opacity)
341                .maybe(opacity > 0., |el| {
342                    el.child(rect().scale(scale).child(popup))
343                }),
344        )
345    }
346
347    fn render_key(&self) -> DiffKey {
348        self.key.clone().or(self.default_key())
349    }
350}