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    Point2D,
20    Position,
21    Size,
22    Size2D,
23};
24
25use crate::{
26    button::Button,
27    context_menu::ContextMenu,
28    define_theme,
29    get_theme,
30    menu::{
31        Menu,
32        MenuButton,
33    },
34};
35
36define_theme! {
37    %[component]
38    pub ColorPicker {
39        %[fields]
40        background: Color,
41        color: Color,
42        border_fill: Color,
43    }
44}
45
46/// HSV-based gradient color picker.
47///
48/// ## Example
49///
50/// ```rust
51/// # use freya::prelude::*;
52/// fn app() -> impl IntoElement {
53///     let mut color = use_state(|| Color::from_hsv(0.0, 1.0, 1.0));
54///     rect()
55///         .padding(6.)
56///         .child(ColorPicker::new(move |c| color.set(c)).value(color()))
57/// }
58/// ```
59///
60/// See the [interactive components demo](https://freyaui.dev/demo).
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 key_mut(&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        let mut preview_area = use_state(|| None::<Area>);
115        let mut popup_size = use_state(|| None::<Size2D>);
116
117        let is_open = open();
118
119        let preview = rect()
120            .width(Size::px(40.))
121            .height(Size::px(24.))
122            .corner_radius(4.)
123            .background(self.value)
124            .on_sized(move |e: Event<SizedEventData>| preview_area.set_if_modified(Some(e.area)))
125            .on_press(move |_| {
126                open.toggle();
127            });
128
129        let theme = get_theme!(&self.theme, ColorPickerThemePreference, "color_picker");
130        let hue_bar = rect()
131            .height(Size::px(18.))
132            .width(Size::fill())
133            .corner_radius(4.)
134            .on_sized(move |e: Event<SizedEventData>| hue_area.set(e.area))
135            .background(
136                LinearGradient::new()
137                    .angle(-90.)
138                    .stop(((255, 0, 0), 0.))
139                    .stop(((255, 255, 0), 16.))
140                    .stop(((0, 255, 0), 33.))
141                    .stop(((0, 255, 255), 50.))
142                    .stop(((0, 0, 255), 66.))
143                    .stop(((255, 0, 255), 83.))
144                    .stop(((255, 0, 0), 100.)),
145            );
146
147        let sv_area = rect()
148            .height(Size::px(140.))
149            .width(Size::fill())
150            .corner_radius(4.)
151            .overflow(Overflow::Clip)
152            .child(
153                rect()
154                    .expanded()
155                    .background(
156                        // left: white -> right: hue color
157                        LinearGradient::new()
158                            .angle(-90.)
159                            .stop(((255, 255, 255), 0.))
160                            .stop((Color::from_hsv(color.read().to_hsv().h, 1.0, 1.0), 100.)),
161                    )
162                    .child(
163                        rect()
164                            .position(Position::new_absolute())
165                            .expanded()
166                            .background(
167                                // top: transparent -> bottom: black
168                                LinearGradient::new()
169                                    .stop(((255, 255, 255, 0.0), 0.))
170                                    .stop(((0, 0, 0), 100.)),
171                            ),
172                    ),
173            );
174
175        let mut update_sv = {
176            let on_change = self.on_change.clone();
177            move |coords: CursorPoint| {
178                let sv_area = area.read().to_f64();
179                let sat = ((coords.x - sv_area.min_x()) / sv_area.width()).clamp(0., 1.) as f32;
180                let rel_y = ((coords.y - sv_area.min_y()) / sv_area.height()).clamp(0., 1.) as f32;
181                let v = 1.0 - rel_y;
182                let hsv = color.read().to_hsv();
183                let new_color = Color::from_hsv(hsv.h, sat, v);
184                color.set_if_modified_and_then(new_color, || on_change.call(new_color));
185            }
186        };
187
188        let mut update_hue = {
189            let on_change = self.on_change.clone();
190            move |coords: CursorPoint| {
191                let bar_area = hue_area.read().to_f64();
192                let rel_x = ((coords.x - bar_area.min_x()) / bar_area.width()).clamp(0., 1.) as f32;
193                let hsv = color.read().to_hsv();
194                let new_color = Color::from_hsv(rel_x * 360.0, hsv.s, hsv.v);
195                color.set_if_modified_and_then(new_color, || on_change.call(new_color));
196            }
197        };
198
199        let on_sv_pointer_down = {
200            let mut update_sv = update_sv.clone();
201            move |e: Event<PointerEventData>| {
202                if !e.data().is_primary() {
203                    return;
204                }
205                dragging.set(DragTarget::Sv);
206                update_sv(e.global_location());
207                e.stop_propagation();
208                e.prevent_default();
209            }
210        };
211
212        let on_hue_pointer_down = {
213            let mut update_hue = update_hue.clone();
214            move |e: Event<PointerEventData>| {
215                if !e.data().is_primary() {
216                    return;
217                }
218                dragging.set(DragTarget::Hue);
219                update_hue(e.global_location());
220                e.stop_propagation();
221                e.prevent_default();
222            }
223        };
224
225        let on_global_pointer_move = move |e: Event<PointerEventData>| match *dragging.read() {
226            DragTarget::Sv => {
227                update_sv(e.global_location());
228            }
229            DragTarget::Hue => {
230                update_hue(e.global_location());
231            }
232            DragTarget::None => {}
233        };
234
235        let on_global_pointer_up = move |_| {
236            // Only close the popup if it wasnt being dragged and it is open
237            if is_open && dragging() == DragTarget::None {
238                open.set(false);
239            }
240            dragging.set_if_modified(DragTarget::None);
241        };
242
243        let animation = use_animation(move |conf| {
244            conf.on_change(OnChange::Rerun);
245            conf.on_creation(OnCreation::Finish);
246
247            let scale = AnimNum::new(0.8, 1.)
248                .time(200)
249                .ease(Ease::Out)
250                .function(Function::Expo);
251            let opacity = AnimNum::new(0., 1.)
252                .time(200)
253                .ease(Ease::Out)
254                .function(Function::Expo);
255
256            if open() {
257                (scale, opacity)
258            } else {
259                (scale, opacity).into_reversed()
260            }
261        });
262
263        let (scale, opacity) = animation.read().value();
264
265        let (offset_x, offset_y, opacity) = match (preview_area(), popup_size()) {
266            (Some(preview), Some(size)) => {
267                let window = Area::from_size(*Platform::get().current_window().root_size.peek());
268                let popup = Area::new(Point2D::new(preview.max_x(), preview.min_y()), size);
269
270                let target = if popup.max_x() > window.max_x() && preview.min_x() >= size.width {
271                    Point2D::new(preview.min_x() - size.width, popup.min_y())
272                } else {
273                    popup.origin
274                };
275
276                let clamped = target.clamp(window.origin, window.max() - size.to_vector());
277                let offset = clamped - popup.origin;
278
279                (offset.x, offset.y, opacity)
280            }
281            _ => (0., 0., 0.),
282        };
283
284        let popup = rect()
285            .on_global_pointer_move(on_global_pointer_move)
286            .on_global_pointer_up(on_global_pointer_up)
287            .width(self.width.clone())
288            .padding(8.)
289            .corner_radius(6.)
290            .background(theme.background)
291            .border(
292                Border::new()
293                    .fill(theme.border_fill)
294                    .width(1.)
295                    .alignment(BorderAlignment::Inner),
296            )
297            .color(theme.color)
298            .spacing(8.)
299            .shadow(Shadow::new().x(0.).y(2.).blur(8.).color((0, 0, 0, 0.1)))
300            .child(
301                rect()
302                    .on_sized(move |e: Event<SizedEventData>| area.set(e.area))
303                    .on_pointer_down(on_sv_pointer_down)
304                    .child(sv_area),
305            )
306            .child(
307                rect()
308                    .height(Size::px(18.))
309                    .on_pointer_down(on_hue_pointer_down)
310                    .child(hue_bar),
311            )
312            .child({
313                let hex = format!(
314                    "#{:02X}{:02X}{:02X}",
315                    color.read().r(),
316                    color.read().g(),
317                    color.read().b()
318                );
319
320                rect()
321                    .horizontal()
322                    .width(Size::fill())
323                    .main_align(Alignment::center())
324                    .spacing(8.)
325                    .child(
326                        Button::new()
327                            .on_press(move |e: Event<PressEventData>| {
328                                e.stop_propagation();
329                                e.prevent_default();
330                                if ContextMenu::is_open() {
331                                    ContextMenu::close();
332                                } else {
333                                    ContextMenu::open(
334                                        Menu::new()
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_rgb_string());
343                                                    })
344                                                    .child("Copy as RGB"),
345                                            )
346                                            .child(
347                                                MenuButton::new()
348                                                    .on_press(move |e: Event<PressEventData>| {
349                                                        e.stop_propagation();
350                                                        e.prevent_default();
351                                                        ContextMenu::close();
352                                                        let _ =
353                                                            Clipboard::set(color().to_hex_string());
354                                                    })
355                                                    .child("Copy as HEX"),
356                                            ),
357                                    )
358                                }
359                            })
360                            .compact()
361                            .child(hex),
362                    )
363            });
364
365        rect()
366            .horizontal()
367            .child(preview)
368            .maybe_child((is_open || opacity > 0.).then(|| {
369                rect()
370                    .layer(Layer::Overlay)
371                    .width(Size::px(0.))
372                    .height(Size::px(0.))
373                    .offset_x(offset_x)
374                    .offset_y(offset_y)
375                    .opacity(opacity)
376                    .child(
377                        popup
378                            .scale(scale)
379                            .on_sized(move |e: Event<SizedEventData>| {
380                                popup_size.set_if_modified(Some(e.area.size))
381                            }),
382                    )
383            }))
384    }
385
386    fn render_key(&self) -> DiffKey {
387        self.key.clone().or(self.default_key())
388    }
389}