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(250)); }).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        // Minimum perceptible floor to avoid full desaturation/black when dragging
183        const MIN_S: f32 = 0.07;
184        const MIN_V: f32 = 0.07;
185
186        let mut update_sv = {
187            let on_change = self.on_change.clone();
188            move |coords: CursorPoint| {
189                let sv_area = area.read().to_f64();
190                let rel_x = (((coords.x - sv_area.min_x()) / sv_area.width()).clamp(0., 1.)) as f32;
191                let rel_y = (((coords.y - sv_area.min_y()) / sv_area.height())
192                    .clamp(MIN_V as f64, 1. - MIN_V as f64)) as f32;
193                let sat = rel_x.max(MIN_S);
194                let v = (1.0 - rel_y).clamp(MIN_V, 1.0 - MIN_V);
195                let hsv = color.read().to_hsv();
196                color.set(Color::from_hsv(hsv.h, sat, v));
197                on_change.call(color());
198            }
199        };
200
201        let mut update_hue = {
202            let on_change = self.on_change.clone();
203            move |coords: CursorPoint| {
204                let bar_area = hue_area.read().to_f64();
205                let rel_x =
206                    ((coords.x - bar_area.min_x()) / bar_area.width()).clamp(0.01, 1.) as f32;
207                let hsv = color.read().to_hsv();
208                color.set(Color::from_hsv(rel_x * 360.0, hsv.s, hsv.v));
209                on_change.call(color());
210            }
211        };
212
213        let on_sv_pointer_down = {
214            let mut update_sv = update_sv.clone();
215            move |e: Event<PointerEventData>| {
216                dragging.set(DragTarget::Sv);
217                update_sv(e.global_location());
218                e.stop_propagation();
219                e.prevent_default();
220            }
221        };
222
223        let on_hue_pointer_down = {
224            let mut update_hue = update_hue.clone();
225            move |e: Event<PointerEventData>| {
226                dragging.set(DragTarget::Hue);
227                update_hue(e.global_location());
228                e.stop_propagation();
229                e.prevent_default();
230            }
231        };
232
233        let on_global_pointer_move = move |e: Event<PointerEventData>| match *dragging.read() {
234            DragTarget::Sv => {
235                update_sv(e.global_location());
236            }
237            DragTarget::Hue => {
238                update_hue(e.global_location());
239            }
240            DragTarget::None => {}
241        };
242
243        let on_global_pointer_press = move |_| {
244            // Only close the popup if it wasnt being dragged and it is open
245            if is_open && dragging() == DragTarget::None {
246                open.set(false);
247            }
248            dragging.set_if_modified(DragTarget::None);
249        };
250
251        let animation = use_animation(move |conf| {
252            conf.on_change(OnChange::Rerun);
253            conf.on_creation(OnCreation::Finish);
254
255            let scale = AnimNum::new(0.8, 1.)
256                .time(200)
257                .ease(Ease::Out)
258                .function(Function::Expo);
259            let opacity = AnimNum::new(0., 1.)
260                .time(200)
261                .ease(Ease::Out)
262                .function(Function::Expo);
263
264            if open() {
265                (scale, opacity)
266            } else {
267                (scale, opacity).into_reversed()
268            }
269        });
270
271        let (scale, opacity) = animation.read().value();
272
273        let popup = rect()
274            .on_global_pointer_move(on_global_pointer_move)
275            .on_global_pointer_press(on_global_pointer_press)
276            .width(self.width.clone())
277            .padding(8.)
278            .corner_radius(6.)
279            .background(theme.background)
280            .border(
281                Border::new()
282                    .fill(theme.border_fill)
283                    .width(1.)
284                    .alignment(BorderAlignment::Inner),
285            )
286            .color(theme.color)
287            .spacing(8.)
288            .shadow(Shadow::new().x(0.).y(2.).blur(8.).color((0, 0, 0, 0.1)))
289            .child(
290                rect()
291                    .on_sized(move |e: Event<SizedEventData>| area.set(e.area))
292                    .on_pointer_down(on_sv_pointer_down)
293                    .child(sv_area),
294            )
295            .child(
296                rect()
297                    .height(Size::px(18.))
298                    .on_pointer_down(on_hue_pointer_down)
299                    .child(hue_bar),
300            )
301            .child({
302                let hex = format!(
303                    "#{:02X}{:02X}{:02X}",
304                    color.read().r(),
305                    color.read().g(),
306                    color.read().b()
307                );
308
309                rect()
310                    .horizontal()
311                    .width(Size::fill())
312                    .main_align(Alignment::center())
313                    .spacing(8.)
314                    .child(
315                        Button::new()
316                            .on_press(move |e: Event<PressEventData>| {
317                                e.stop_propagation();
318                                e.prevent_default();
319                                if ContextMenu::is_open() {
320                                    ContextMenu::close();
321                                } else {
322                                    ContextMenu::open_from_event(
323                                        &e,
324                                        Menu::new()
325                                            .child(
326                                                MenuButton::new()
327                                                    .on_press(move |e: Event<PressEventData>| {
328                                                        e.stop_propagation();
329                                                        e.prevent_default();
330                                                        ContextMenu::close();
331                                                        let _ =
332                                                            Clipboard::set(color().to_rgb_string());
333                                                    })
334                                                    .child("Copy as RGB"),
335                                            )
336                                            .child(
337                                                MenuButton::new()
338                                                    .on_press(move |e: Event<PressEventData>| {
339                                                        e.stop_propagation();
340                                                        e.prevent_default();
341                                                        ContextMenu::close();
342                                                        let _ =
343                                                            Clipboard::set(color().to_hex_string());
344                                                    })
345                                                    .child("Copy as HEX"),
346                                            ),
347                                    )
348                                }
349                            })
350                            .compact()
351                            .child(hex),
352                    )
353            });
354
355        rect()
356            .horizontal()
357            .spacing(8.)
358            .child(preview)
359            .maybe_child((opacity > 0.).then(|| {
360                rect()
361                    .layer(Layer::Overlay)
362                    .width(Size::px(0.))
363                    .height(Size::px(0.))
364                    .opacity(opacity)
365                    .child(rect().scale(scale).child(popup))
366            }))
367    }
368
369    fn render_key(&self) -> DiffKey {
370        self.key.clone().or(self.default_key())
371    }
372}