Skip to main content

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