@perspective-dev/viewer 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/cdn/perspective-viewer.js +2 -2
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/monokai.css +1 -1
  9. package/dist/css/phosphor.css +1 -1
  10. package/dist/css/pro-dark.css +1 -1
  11. package/dist/css/pro.css +1 -1
  12. package/dist/css/solarized-dark.css +1 -1
  13. package/dist/css/solarized.css +1 -1
  14. package/dist/css/themes.css +1 -1
  15. package/dist/css/vaporwave.css +1 -1
  16. package/dist/docs/perspective-docs.json +1 -1
  17. package/dist/esm/perspective-viewer.d.ts +1 -0
  18. package/dist/esm/perspective-viewer.inline.js +9 -9
  19. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  20. package/dist/esm/perspective-viewer.js +4 -4
  21. package/dist/esm/perspective-viewer.js.map +2 -2
  22. package/dist/esm/plugin.d.ts +10 -0
  23. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  24. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  25. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  26. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  27. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  28. package/dist/wasm/perspective-viewer.d.ts +43 -36
  29. package/dist/wasm/perspective-viewer.js +194 -136
  30. package/dist/wasm/perspective-viewer.wasm +0 -0
  31. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  32. package/package.json +1 -1
  33. package/src/css/column-settings-panel.css +9 -5
  34. package/src/css/column-style.css +209 -31
  35. package/src/css/dom/select.css +2 -1
  36. package/src/css/form/code-editor.css +4 -4
  37. package/src/rust/agent/tools.rs +48 -17
  38. package/src/rust/components/column_selector/active_column.rs +2 -2
  39. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  40. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  41. package/src/rust/components/column_selector/config_selector.rs +8 -0
  42. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  43. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  44. package/src/rust/components/column_selector.rs +48 -4
  45. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  46. package/src/rust/components/column_settings_sidebar/style_tab.rs +262 -225
  47. package/src/rust/components/column_settings_sidebar.rs +88 -69
  48. package/src/rust/components/editable_header.rs +85 -144
  49. package/src/rust/components/expression_editor.rs +9 -16
  50. package/src/rust/components/form/code_editor.rs +16 -6
  51. package/src/rust/components/form/debug.rs +1 -0
  52. package/src/rust/components/form/mod.rs +3 -1
  53. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  54. package/src/rust/components/form/named_value_picker.rs +86 -0
  55. package/src/rust/components/form/palette_selector.rs +440 -0
  56. package/src/rust/components/main_panel/msg.rs +2 -2
  57. package/src/rust/components/main_panel/reconcile.rs +4 -4
  58. package/src/rust/components/plugin_tab.rs +4 -28
  59. package/src/rust/components/settings_panel.rs +25 -20
  60. package/src/rust/components/viewer/msg.rs +46 -7
  61. package/src/rust/components/viewer/panels.rs +11 -9
  62. package/src/rust/components/viewer/render.rs +8 -3
  63. package/src/rust/components/viewer/settings.rs +163 -23
  64. package/src/rust/components/viewer/snapshots.rs +46 -21
  65. package/src/rust/components/viewer/wiring.rs +14 -25
  66. package/src/rust/components/viewer.rs +12 -8
  67. package/src/rust/config/column_config_schema.rs +100 -41
  68. package/src/rust/config/css_scale.rs +882 -0
  69. package/src/rust/config/mod.rs +2 -0
  70. package/src/rust/config/options.rs +10 -0
  71. package/src/rust/config/viewer_config.rs +49 -55
  72. package/src/rust/config/workspace_config.rs +132 -5
  73. package/src/rust/custom_elements/viewer.rs +214 -155
  74. package/src/rust/custom_events.rs +14 -1
  75. package/src/rust/js/regular_layout.rs +3 -3
  76. package/src/rust/lib.rs +2 -0
  77. package/src/rust/presentation/column_locator.rs +19 -5
  78. package/src/rust/presentation.rs +192 -36
  79. package/src/rust/queries/column_locator.rs +74 -44
  80. package/src/rust/queries/get_viewer_config.rs +0 -3
  81. package/src/rust/queries/mod.rs +2 -0
  82. package/src/rust/queries/palette_set.rs +82 -0
  83. package/src/rust/queries/plugin_column_styles.rs +6 -2
  84. package/src/rust/renderer/dispatch.rs +16 -4
  85. package/src/rust/renderer/plugin_config.rs +309 -47
  86. package/src/rust/renderer.rs +15 -42
  87. package/src/rust/session/column_defaults_update.rs +15 -8
  88. package/src/rust/session/metadata.rs +5 -1
  89. package/src/rust/session/props.rs +1 -0
  90. package/src/rust/session.rs +9 -6
  91. package/src/rust/tasks/create_panel.rs +44 -57
  92. package/src/rust/tasks/edit_expression.rs +19 -23
  93. package/src/rust/tasks/edit_window.rs +8 -10
  94. package/src/rust/tasks/eject.rs +4 -1
  95. package/src/rust/tasks/pipeline.rs +2 -2
  96. package/src/rust/tasks/presize_panels.rs +44 -5
  97. package/src/rust/tasks/reset_all.rs +4 -4
  98. package/src/rust/tasks/restore_and_render.rs +17 -44
  99. package/src/rust/tasks/restore_panel.rs +11 -13
  100. package/src/rust/tasks/set_edit_mode.rs +3 -1
  101. package/src/rust/tasks/sync_update_panels.rs +11 -1
  102. package/src/rust/tasks/update_theme.rs +23 -18
  103. package/src/rust/utils/css_vars.rs +26 -0
  104. package/src/rust/utils/mod.rs +2 -0
  105. package/src/rust/utils/spawn.rs +1 -1
  106. package/src/rust/workspace.rs +435 -272
  107. package/src/svg/lock.svg +7 -0
  108. package/src/themes/botanical.css +10 -0
  109. package/src/themes/defaults.css +12 -0
  110. package/src/themes/dracula.css +17 -0
  111. package/src/themes/gruvbox-dark.css +20 -0
  112. package/src/themes/gruvbox.css +10 -0
  113. package/src/themes/icons.css +1 -0
  114. package/src/themes/monokai.css +22 -0
  115. package/src/themes/phosphor.css +5 -0
  116. package/src/themes/pro-dark.css +10 -0
  117. package/src/themes/solarized-dark.css +7 -0
  118. package/src/themes/solarized.css +7 -0
  119. package/src/themes/vaporwave.css +20 -0
  120. package/src/ts/perspective-viewer.ts +1 -0
  121. package/src/ts/plugin.ts +14 -0
  122. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  123. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  124. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  125. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  126. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  127. package/src/rust/components/form/color_range_selector.rs +0 -137
  128. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline0.js +0 -0
  129. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline1.js +0 -0
  130. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline2.js +0 -0
  131. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline3.js +0 -0
  132. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline4.js +0 -0
  133. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline5.js +0 -0
@@ -0,0 +1,476 @@
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
+
13
+ use itertools::Itertools;
14
+ use wasm_bindgen::JsCast;
15
+ use web_sys::*;
16
+ use yew::prelude::*;
17
+
18
+ use 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)]
23
+ pub 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
+
36
+ fn event_index(target: &Element) -> Option<usize> {
37
+ target
38
+ .get_attribute("data-index")
39
+ .and_then(|x| x.parse::<usize>().ok())
40
+ }
41
+
42
+ fn 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
+
54
+ pub(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
+
99
+ const EDGE_PAD_PX: f64 = 8.0;
100
+
101
+ fn 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
+
108
+ fn 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
+
122
+ fn 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
+
138
+ fn 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)]
149
+ pub 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)]
390
+ mod 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
+ }
@@ -0,0 +1,86 @@
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
+
13
+ use std::rc::Rc;
14
+
15
+ use wasm_bindgen::JsCast;
16
+ use web_sys::{Event, HtmlSelectElement, MouseEvent};
17
+ use yew::prelude::*;
18
+
19
+ use crate::config::{CssKind, NamedValue};
20
+
21
+ /// The named-value controls above a CSS-valued style field: a stateless
22
+ /// quick-action `<select>` over every named value of the field's kind,
23
+ /// and a Pin button that force-adds the field's value to the workspace
24
+ /// palette.
25
+ #[derive(Properties, PartialEq)]
26
+ pub struct NamedValuePickerProps {
27
+ pub kind: CssKind,
28
+ pub entries: Rc<Vec<NamedValue>>,
29
+
30
+ /// Loads the named entry's value into the field.
31
+ pub on_select: Callback<String>,
32
+
33
+ #[prop_or_default]
34
+ pub can_pin: bool,
35
+
36
+ #[prop_or_default]
37
+ pub on_pin: Callback<()>,
38
+ }
39
+
40
+ const PROMPT: &str = "";
41
+
42
+ #[function_component(NamedValuePicker)]
43
+ pub fn named_value_picker(props: &NamedValuePickerProps) -> Html {
44
+ let kind = props.kind;
45
+ let onchange = use_callback(props.on_select.clone(), |event: Event, on_select| {
46
+ let Some(select) = event
47
+ .target()
48
+ .and_then(|x| x.dyn_into::<HtmlSelectElement>().ok())
49
+ else {
50
+ return;
51
+ };
52
+
53
+ let name = select.value();
54
+ select.set_value(PROMPT);
55
+ if !name.is_empty() {
56
+ on_select.emit(name);
57
+ }
58
+ });
59
+
60
+ let on_pin = use_callback(props.on_pin.clone(), |event: MouseEvent, on_pin| {
61
+ event.prevent_default();
62
+ on_pin.emit(());
63
+ });
64
+
65
+ html! {
66
+ <div class="named-value-controls">
67
+ if !props.entries.is_empty() {
68
+ <select class="named-value-select" title="Load a named value" {onchange}>
69
+ <option value={PROMPT} selected=true disabled=true>{ "Load" }</option>
70
+ { for props.entries.iter().map(|entry| html! {
71
+ <option key={entry.name.clone()} value={entry.name.clone()}>
72
+ { kind.short_name(&entry.name) }
73
+ </option>
74
+ }) }
75
+ </select>
76
+ }
77
+ if props.can_pin {
78
+ <span
79
+ class="named-value-pin"
80
+ title="Pin this value to the workspace palette"
81
+ onmousedown={on_pin}
82
+ />
83
+ }
84
+ </div>
85
+ }
86
+ }