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