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