@perspective-dev/viewer 5.2.0 → 5.3.1

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 (179) hide show
  1. package/dist/cdn/perspective-viewer.js +8 -8
  2. package/dist/cdn/perspective-viewer.js.map +3 -3
  3. package/dist/css/blueprint.css +1 -0
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -0
  7. package/dist/css/gruvbox-dark.css +1 -1
  8. package/dist/css/gruvbox.css +1 -1
  9. package/dist/css/icons.css +1 -1
  10. package/dist/css/intl/de.css +1 -1
  11. package/dist/css/intl/es.css +1 -1
  12. package/dist/css/intl/fr.css +1 -1
  13. package/dist/css/intl/ja.css +1 -1
  14. package/dist/css/intl/pt.css +1 -1
  15. package/dist/css/intl/zh.css +1 -1
  16. package/dist/css/intl.css +1 -1
  17. package/dist/css/ledger.css +1 -0
  18. package/dist/css/monokai.css +1 -1
  19. package/dist/css/nord.css +1 -0
  20. package/dist/css/phosphor.css +1 -1
  21. package/dist/css/pro-dark.css +1 -1
  22. package/dist/css/pro.css +1 -1
  23. package/dist/css/solarized-dark.css +1 -1
  24. package/dist/css/solarized.css +1 -1
  25. package/dist/css/themes.css +1 -1
  26. package/dist/css/vaporwave.css +1 -1
  27. package/dist/css/velvet.css +1 -0
  28. package/dist/docs/perspective-docs.json +1 -1
  29. package/dist/esm/column-format.d.ts +16 -3
  30. package/dist/esm/perspective-viewer.d.ts +1 -0
  31. package/dist/esm/perspective-viewer.inline.js +9 -9
  32. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  33. package/dist/esm/perspective-viewer.js +8 -8
  34. package/dist/esm/perspective-viewer.js.map +3 -3
  35. package/dist/esm/plugin.d.ts +10 -0
  36. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  37. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  38. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  39. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  40. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  41. package/dist/wasm/perspective-viewer.d.ts +43 -36
  42. package/dist/wasm/perspective-viewer.js +198 -136
  43. package/dist/wasm/perspective-viewer.wasm +0 -0
  44. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  45. package/package.json +1 -1
  46. package/src/css/column-settings-panel.css +28 -5
  47. package/src/css/column-style.css +291 -31
  48. package/src/css/containers/tabs.css +13 -3
  49. package/src/css/dom/select.css +21 -9
  50. package/src/css/form/code-editor.css +4 -4
  51. package/src/css/plugin-selector.css +1 -0
  52. package/src/css/plugin-settings-panel.css +72 -0
  53. package/src/css/viewer.css +0 -5
  54. package/src/rust/agent/tools.rs +53 -19
  55. package/src/rust/components/column_selector/active_column.rs +2 -2
  56. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  57. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  58. package/src/rust/components/column_selector/config_selector.rs +8 -0
  59. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  60. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  61. package/src/rust/components/column_selector.rs +48 -4
  62. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  63. package/src/rust/components/column_settings_sidebar/style_tab.rs +330 -234
  64. package/src/rust/components/column_settings_sidebar.rs +88 -69
  65. package/src/rust/components/containers/control_group.rs +91 -0
  66. package/src/rust/components/containers/mod.rs +1 -0
  67. package/src/rust/components/containers/select.rs +8 -2
  68. package/src/rust/components/datetime_column_style/simple.rs +15 -4
  69. package/src/rust/components/datetime_column_style.rs +50 -10
  70. package/src/rust/components/editable_header.rs +85 -144
  71. package/src/rust/components/expression_editor.rs +9 -16
  72. package/src/rust/components/form/code_editor.rs +16 -6
  73. package/src/rust/components/form/debug.rs +1 -0
  74. package/src/rust/components/form/mod.rs +3 -1
  75. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  76. package/src/rust/components/form/named_value_picker.rs +86 -0
  77. package/src/rust/components/form/palette_selector.rs +440 -0
  78. package/src/rust/components/main_panel/msg.rs +2 -2
  79. package/src/rust/components/main_panel/reconcile.rs +4 -4
  80. package/src/rust/components/plugin_tab.rs +126 -112
  81. package/src/rust/components/settings_panel.rs +26 -20
  82. package/src/rust/components/status_bar.rs +0 -1
  83. package/src/rust/components/style_controls/number_string_format/digits_section.rs +22 -10
  84. package/src/rust/components/style_controls/number_string_format/misc_section.rs +6 -2
  85. package/src/rust/components/style_controls/number_string_format/style_section.rs +1 -0
  86. package/src/rust/components/style_controls/number_string_format/types.rs +24 -0
  87. package/src/rust/components/style_controls/number_string_format.rs +37 -14
  88. package/src/rust/components/viewer/msg.rs +46 -7
  89. package/src/rust/components/viewer/panels.rs +11 -9
  90. package/src/rust/components/viewer/render.rs +8 -3
  91. package/src/rust/components/viewer/settings.rs +163 -23
  92. package/src/rust/components/viewer/snapshots.rs +46 -21
  93. package/src/rust/components/viewer/wiring.rs +14 -25
  94. package/src/rust/components/viewer.rs +12 -8
  95. package/src/rust/config/column_config_schema.rs +435 -45
  96. package/src/rust/config/css_scale.rs +882 -0
  97. package/src/rust/config/datetime_column_style.rs +4 -21
  98. package/src/rust/config/mod.rs +2 -0
  99. package/src/rust/config/number_string_format.rs +211 -18
  100. package/src/rust/config/options.rs +10 -0
  101. package/src/rust/config/viewer_config.rs +49 -55
  102. package/src/rust/config/workspace_config.rs +132 -5
  103. package/src/rust/custom_elements/viewer.rs +214 -155
  104. package/src/rust/custom_events.rs +14 -1
  105. package/src/rust/js/regular_layout.rs +3 -3
  106. package/src/rust/lib.rs +2 -0
  107. package/src/rust/presentation/column_locator.rs +19 -5
  108. package/src/rust/presentation.rs +209 -36
  109. package/src/rust/queries/column_locator.rs +74 -44
  110. package/src/rust/queries/get_viewer_config.rs +0 -3
  111. package/src/rust/queries/mod.rs +2 -0
  112. package/src/rust/queries/palette_set.rs +82 -0
  113. package/src/rust/queries/plugin_column_styles.rs +6 -2
  114. package/src/rust/renderer/dispatch.rs +50 -12
  115. package/src/rust/renderer/plugin_config.rs +362 -53
  116. package/src/rust/renderer.rs +15 -42
  117. package/src/rust/session/column_defaults_update.rs +15 -8
  118. package/src/rust/session/metadata.rs +5 -1
  119. package/src/rust/session/props.rs +1 -0
  120. package/src/rust/session.rs +9 -6
  121. package/src/rust/tasks/create_panel.rs +44 -57
  122. package/src/rust/tasks/edit_expression.rs +19 -23
  123. package/src/rust/tasks/edit_window.rs +8 -10
  124. package/src/rust/tasks/eject.rs +4 -1
  125. package/src/rust/tasks/pipeline.rs +2 -2
  126. package/src/rust/tasks/presize_panels.rs +44 -5
  127. package/src/rust/tasks/reset_all.rs +4 -4
  128. package/src/rust/tasks/restore_and_render.rs +17 -44
  129. package/src/rust/tasks/restore_panel.rs +11 -13
  130. package/src/rust/tasks/set_edit_mode.rs +3 -1
  131. package/src/rust/tasks/sync_update_panels.rs +11 -1
  132. package/src/rust/tasks/update_theme.rs +23 -18
  133. package/src/rust/utils/css_vars.rs +26 -0
  134. package/src/rust/utils/mod.rs +2 -0
  135. package/src/rust/utils/spawn.rs +1 -1
  136. package/src/rust/workspace.rs +435 -272
  137. package/src/svg/lock.svg +7 -0
  138. package/src/themes/blueprint.css +190 -0
  139. package/src/themes/botanical.css +14 -17
  140. package/src/themes/defaults.css +4 -110
  141. package/src/themes/dracula.css +19 -14
  142. package/src/themes/eggplant.css +189 -0
  143. package/src/themes/gruvbox-dark.css +23 -15
  144. package/src/themes/gruvbox.css +11 -1
  145. package/src/themes/icons.css +2 -2
  146. package/src/themes/intl/de.css +23 -1
  147. package/src/themes/intl/es.css +23 -1
  148. package/src/themes/intl/fr.css +23 -1
  149. package/src/themes/intl/ja.css +23 -1
  150. package/src/themes/intl/pt.css +23 -1
  151. package/src/themes/intl/zh.css +23 -1
  152. package/src/themes/intl.css +26 -4
  153. package/src/themes/ledger.css +195 -0
  154. package/src/themes/monokai.css +24 -14
  155. package/src/themes/nord.css +186 -0
  156. package/src/themes/phosphor.css +7 -14
  157. package/src/themes/pro-dark.css +12 -16
  158. package/src/themes/pro.css +118 -0
  159. package/src/themes/solarized-dark.css +9 -16
  160. package/src/themes/solarized.css +7 -0
  161. package/src/themes/themes.css +10 -5
  162. package/src/themes/vaporwave.css +22 -14
  163. package/src/themes/velvet.css +192 -0
  164. package/src/ts/column-format.ts +52 -2
  165. package/src/ts/perspective-viewer.ts +1 -0
  166. package/src/ts/plugin.ts +14 -0
  167. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  168. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  169. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  170. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  171. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  172. package/src/rust/components/form/color_range_selector.rs +0 -137
  173. package/src/svg/dropdown-selector-light.svg +0 -3
  174. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline0.js +0 -0
  175. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline1.js +0 -0
  176. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline2.js +0 -0
  177. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline3.js +0 -0
  178. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline4.js +0 -0
  179. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/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
+ }