Skip to main content

perspective_viewer/components/form/
multi_stop_gradient_selector.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13use itertools::Itertools;
14use wasm_bindgen::JsCast;
15use web_sys::*;
16use yew::prelude::*;
17
18use crate::config::{GradientStopSpec, canonicalize_gradient_stops};
19
20/// The widget for [`crate::config::ControlSpec::GradientStops`]: a
21/// horizontal gradient preview bar with one handle per stop.
22#[derive(Properties, PartialEq)]
23pub struct MultiStopGradientProps {
24    pub stops: Vec<GradientStopSpec>,
25    pub on_change: Callback<Vec<GradientStopSpec>>,
26    pub on_reset: Callback<()>,
27    pub is_modified: bool,
28
29    #[prop_or_default]
30    pub discrete: bool,
31
32    #[prop_or_default]
33    pub title: Option<String>,
34}
35
36fn event_index(target: &Element) -> Option<usize> {
37    target
38        .get_attribute("data-index")
39        .and_then(|x| x.parse::<usize>().ok())
40}
41
42fn parse_hex_color(color: &str) -> Option<(f64, f64, f64)> {
43    let hex = color.strip_prefix('#')?;
44    if hex.len() != 6 {
45        return None;
46    }
47
48    let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
49    let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
50    let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
51    Some((r as f64, g as f64, b as f64))
52}
53
54pub(crate) fn sample_gradient_hex(stops: &[GradientStopSpec], t: f64) -> String {
55    let Some(first) = stops.first() else {
56        return "#000000".to_owned();
57    };
58
59    if t <= first.offset {
60        return first.color.clone();
61    }
62
63    let last = stops.last().unwrap();
64    if t >= last.offset {
65        return last.color.clone();
66    }
67
68    for pair in stops.windows(2) {
69        let (a, b) = (&pair[0], &pair[1]);
70        if t > b.offset {
71            continue;
72        }
73
74        let span = b.offset - a.offset;
75        let u = if span > 0.0 {
76            (t - a.offset) / span
77        } else {
78            0.0
79        };
80        let nearer = if u < 0.5 { a } else { b };
81        let (Some((ar, ag, ab)), Some((br, bg, bb))) =
82            (parse_hex_color(&a.color), parse_hex_color(&b.color))
83        else {
84            return nearer.color.clone();
85        };
86
87        let scale = |x: f64, y: f64| ((x + (y - x) * u).round().clamp(0.0, 255.0)) as u8;
88        return format!(
89            "#{:02x}{:02x}{:02x}",
90            scale(ar, br),
91            scale(ag, bg),
92            scale(ab, bb)
93        );
94    }
95
96    last.color.clone()
97}
98
99const EDGE_PAD_PX: f64 = 8.0;
100
101fn track_position(offset: f64) -> String {
102    format!(
103        "calc({EDGE_PAD_PX}px + (100% - {:.0}px) * {offset:.4})",
104        EDGE_PAD_PX * 2.0
105    )
106}
107
108fn css_gradient(stops: &[GradientStopSpec]) -> String {
109    let body = stops
110        .iter()
111        .sorted_by(|a, b| {
112            a.offset
113                .partial_cmp(&b.offset)
114                .unwrap_or(std::cmp::Ordering::Equal)
115        })
116        .map(|stop| format!("{} {}", stop.color, track_position(stop.offset)))
117        .join(", ");
118
119    format!("background:linear-gradient(to right, {body})")
120}
121
122fn equalized_offset(stops: &[GradientStopSpec], index: usize) -> f64 {
123    let left = if index == 0 {
124        0.0
125    } else {
126        stops[index - 1].offset
127    };
128
129    let right = if index + 1 < stops.len() {
130        stops[index + 1].offset
131    } else {
132        1.0
133    };
134
135    (left + right) / 2.0
136}
137
138fn offset_from_pointer(bar: &Option<Element>, client_x: i32) -> Option<f64> {
139    let rect = bar.as_ref()?.get_bounding_client_rect();
140    let track_width = rect.width() - 2.0 * EDGE_PAD_PX;
141    if track_width <= 0.0 {
142        return None;
143    }
144
145    Some(((client_x as f64 - rect.left() - EDGE_PAD_PX) / track_width).clamp(0.0, 1.0))
146}
147
148#[function_component(MultiStopGradientSelector)]
149pub fn multi_stop_gradient_selector(props: &MultiStopGradientProps) -> Html {
150    let bar_ref = use_node_ref();
151
152    let drag = use_state(|| Option::<(usize, f64)>::None);
153
154    let display = {
155        let mut stops = props.stops.clone();
156        if let Some((index, offset)) = *drag
157            && let Some(stop) = stops.get_mut(index)
158        {
159            stop.offset = offset;
160        }
161
162        stops
163    };
164
165    let on_color = use_callback(
166        (props.stops.clone(), props.on_change.clone()),
167        |event: InputEvent, (stops, on_change)| {
168            let input = event.target().unwrap().unchecked_into::<HtmlInputElement>();
169
170            let Some(index) = event_index(&input) else {
171                return;
172            };
173
174            let mut next = stops.clone();
175            let Some(stop) = next.get_mut(index) else {
176                return;
177            };
178
179            stop.color = input.value();
180            on_change.emit(canonicalize_gradient_stops(next));
181        },
182    );
183
184    let on_pointerdown = {
185        let drag = drag.clone();
186        let stops = props.stops.clone();
187        Callback::from(move |event: PointerEvent| {
188            let Some(target) = event.target().and_then(|x| x.dyn_into::<Element>().ok()) else {
189                return;
190            };
191
192            let Some(index) = event_index(&target) else {
193                return;
194            };
195
196            let Some(stop) = stops.get(index) else {
197                return;
198            };
199
200            let _ = target.set_pointer_capture(event.pointer_id());
201            drag.set(Some((index, stop.offset)));
202        })
203    };
204
205    let on_pointermove = {
206        let drag = drag.clone();
207        let bar_ref = bar_ref.clone();
208        Callback::from(move |event: PointerEvent| {
209            let Some((index, _)) = *drag else {
210                return;
211            };
212
213            let Some(offset) = offset_from_pointer(&bar_ref.cast::<Element>(), event.client_x())
214            else {
215                return;
216            };
217
218            drag.set(Some((index, offset)));
219        })
220    };
221
222    let on_pointerup = {
223        let drag = drag.clone();
224        let bar_ref = bar_ref.clone();
225        let stops = props.stops.clone();
226        let on_change = props.on_change.clone();
227        Callback::from(move |event: PointerEvent| {
228            let Some((index, offset)) = *drag else {
229                return;
230            };
231
232            let offset =
233                offset_from_pointer(&bar_ref.cast::<Element>(), event.client_x()).unwrap_or(offset);
234
235            drag.set(None);
236
237            let unchanged = stops
238                .get(index)
239                .map(|stop| (stop.offset * 1000.0).round() == (offset * 1000.0).round())
240                .unwrap_or(true);
241
242            if unchanged {
243                return;
244            }
245
246            let mut next = stops.clone();
247            if let Some(stop) = next.get_mut(index) {
248                stop.offset = offset;
249            }
250
251            on_change.emit(canonicalize_gradient_stops(next));
252        })
253    };
254
255    let on_grip_dblclick = use_callback(
256        (props.stops.clone(), props.on_change.clone()),
257        |event: MouseEvent, (stops, on_change)| {
258            event.stop_propagation();
259            let Some(target) = event.target().and_then(|x| x.dyn_into::<Element>().ok()) else {
260                return;
261            };
262
263            let Some(index) = event_index(&target) else {
264                return;
265            };
266
267            if index >= stops.len() {
268                return;
269            }
270
271            let mut next = stops.clone();
272            next[index].offset = equalized_offset(stops, index);
273            on_change.emit(canonicalize_gradient_stops(next));
274        },
275    );
276
277    let on_remove = use_callback(
278        (props.stops.clone(), props.on_change.clone()),
279        |event: MouseEvent, (stops, on_change)| {
280            let Some(target) = event.target().and_then(|x| x.dyn_into::<Element>().ok()) else {
281                return;
282            };
283
284            let Some(index) = event_index(&target) else {
285                return;
286            };
287
288            if stops.len() > 2 && index < stops.len() {
289                let mut next = stops.clone();
290                next.remove(index);
291                on_change.emit(canonicalize_gradient_stops(next));
292            }
293        },
294    );
295
296    let can_add = !props.discrete;
297
298    let on_dblclick = {
299        let bar_ref = bar_ref.clone();
300        let stops = props.stops.clone();
301        let on_change = props.on_change.clone();
302        Callback::from(move |event: MouseEvent| {
303            if !can_add {
304                return;
305            }
306
307            let Some(bar) = bar_ref.cast::<Element>() else {
308                return;
309            };
310
311            if event.target() != Some(bar.clone().unchecked_into()) {
312                return;
313            }
314
315            let Some(offset) = offset_from_pointer(&Some(bar), event.client_x()) else {
316                return;
317            };
318
319            let mut next = stops.clone();
320            next.push(GradientStopSpec {
321                color: sample_gradient_hex(&next, offset),
322                offset,
323            });
324
325            on_change.emit(canonicalize_gradient_stops(next));
326        })
327    };
328
329    let on_reset = use_callback(props.on_reset.clone(), |_: MouseEvent, on_reset| {
330        on_reset.emit(())
331    });
332
333    let can_remove = props.stops.len() > 2;
334    let can_drag = !props.discrete;
335    let pinned = props.discrete && !can_remove;
336    html! {
337        <>
338            <label id={props.title.as_deref().unwrap_or("gradient-label").to_owned()} />
339            <div class="color-gradient-container gradient-stops-selector">
340                <div
341                    class={if props.discrete {"gradient-stops-bar discrete"} else {"gradient-stops-bar"}}
342                    ref={bar_ref}
343                    style={css_gradient(&display)}
344                    onclick={on_dblclick}
345                >
346                    { for display.iter().enumerate().map(|(index, stop)| html! {
347                        <div
348                            class="gradient-stop-handle"
349                            key={index.to_string()}
350                            style={format!("left:{}", track_position(stop.offset))}
351                        >
352                            <span
353                                class={classes!("gradient-stop-grip", (!can_drag).then_some("disabled"))}
354                                data-index={index.to_string()}
355                                onpointerdown={can_drag.then(|| on_pointerdown.clone())}
356                                onpointermove={can_drag.then(|| on_pointermove.clone())}
357                                onpointerup={can_drag.then(|| on_pointerup.clone())}
358                                ondblclick={can_drag.then(|| on_grip_dblclick.clone())}
359                            />
360                            if pinned {
361                                <span class="gradient-stop-lock" />
362                            } else {
363                                <span
364                                    class={classes!("gradient-stop-remove", (!can_remove).then_some("disabled"))}
365                                    data-index={index.to_string()}
366                                    onclick={can_remove.then(|| on_remove.clone())}
367                                />
368                            }
369                            <input
370                                class="parameter"
371                                type="color"
372                                value={stop.color.to_owned()}
373                                data-index={index.to_string()}
374                                oninput={on_color.clone()}
375                            />
376                        </div>
377                    }) }
378                </div>
379                if props.is_modified {
380                    <span class="reset-default-style" onclick={on_reset} />
381                } else {
382                    <span class="reset-default-style-disabled" />
383                }
384            </div>
385        </>
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn stop(color: &str, offset: f64) -> GradientStopSpec {
394        GradientStopSpec {
395            color: color.to_owned(),
396            offset,
397        }
398    }
399
400    #[test]
401    fn sample_clamps_outside_stop_range() {
402        let stops = vec![stop("#000000", 0.25), stop("#ff0000", 0.75)];
403        assert_eq!(sample_gradient_hex(&stops, 0.0), "#000000");
404        assert_eq!(sample_gradient_hex(&stops, 1.0), "#ff0000");
405    }
406
407    #[test]
408    fn sample_interpolates_linearly() {
409        let stops = vec![stop("#000000", 0.0), stop("#ff0000", 1.0)];
410        assert_eq!(sample_gradient_hex(&stops, 0.5), "#800000");
411
412        let stops = vec![
413            stop("#000000", 0.0),
414            stop("#ffffff", 0.5),
415            stop("#ff0000", 1.0),
416        ];
417
418        assert_eq!(sample_gradient_hex(&stops, 0.25), "#808080");
419        assert_eq!(sample_gradient_hex(&stops, 0.75), "#ff8080");
420    }
421
422    #[test]
423    fn track_positions_pad_the_bar_edges() {
424        assert_eq!(track_position(0.0), "calc(8px + (100% - 16px) * 0.0000)");
425        assert_eq!(track_position(1.0), "calc(8px + (100% - 16px) * 1.0000)");
426        assert_eq!(track_position(0.333), "calc(8px + (100% - 16px) * 0.3330)");
427    }
428
429    #[test]
430    fn gradient_interpolates_on_padded_track() {
431        let stops = vec![
432            stop("#333333", 1.0),
433            stop("#111111", 0.0),
434            stop("#222222", 0.5),
435        ];
436
437        assert_eq!(
438            css_gradient(&stops),
439            format!(
440                "background:linear-gradient(to right, #111111 {}, #222222 {}, #333333 {})",
441                track_position(0.0),
442                track_position(0.5),
443                track_position(1.0)
444            )
445        );
446    }
447
448    #[test]
449    fn equalize_centers_between_neighbors_or_edges() {
450        let stops = vec![
451            stop("#111111", 0.2),
452            stop("#222222", 0.25),
453            stop("#333333", 0.4),
454        ];
455
456        assert_eq!(equalized_offset(&stops, 1), (0.2 + 0.4) / 2.0);
457
458        assert_eq!(equalized_offset(&stops, 0), 0.125);
459        assert_eq!(equalized_offset(&stops, 2), (0.25 + 1.0) / 2.0);
460
461        let single = vec![stop("#111111", 0.9)];
462        assert_eq!(equalized_offset(&single, 0), 0.5);
463    }
464
465    #[test]
466    fn sample_degenerate_cases() {
467        assert_eq!(sample_gradient_hex(&[], 0.5), "#000000");
468        let stops = vec![stop("#123456", 0.5)];
469        assert_eq!(sample_gradient_hex(&stops, 0.1), "#123456");
470        assert_eq!(sample_gradient_hex(&stops, 0.9), "#123456");
471
472        let stops = vec![stop("red", 0.0), stop("blue", 1.0)];
473        assert_eq!(sample_gradient_hex(&stops, 0.25), "red");
474        assert_eq!(sample_gradient_hex(&stops, 0.75), "blue");
475    }
476}