Skip to main content

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