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