perspective_viewer/ui/form/
multi_stop_gradient_selector.rs1use 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}