@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,882 @@
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::collections::BTreeMap;
14
+
15
+ use serde_json::Value;
16
+
17
+ use super::{GradientStopSpec, canonicalize_gradient_stops};
18
+
19
+ pub const USER_VAR_PREFIX: &str = "--psp-user--";
20
+
21
+ #[derive(Clone, Debug, PartialEq)]
22
+ pub struct NamedValue {
23
+ pub name: String,
24
+ pub value: String,
25
+ }
26
+
27
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28
+ pub enum CssKind {
29
+ Gradient,
30
+ Palette,
31
+ Color,
32
+ }
33
+
34
+ impl CssKind {
35
+ pub const ALL: [CssKind; 3] = [CssKind::Gradient, CssKind::Palette, CssKind::Color];
36
+
37
+ pub fn var_prefix(self) -> &'static str {
38
+ match self {
39
+ CssKind::Gradient => "--psp-user--gradient-",
40
+ CssKind::Palette => "--psp-user--palette-",
41
+ CssKind::Color => "--psp-user--color-",
42
+ }
43
+ }
44
+
45
+ pub fn of_var(name: &str) -> Option<CssKind> {
46
+ CssKind::ALL
47
+ .into_iter()
48
+ .find(|kind| name.starts_with(kind.var_prefix()))
49
+ }
50
+
51
+ pub fn canonicalize(self, src: &str) -> Result<String, String> {
52
+ match self {
53
+ CssKind::Gradient => CssGradient::parse(src).map(|x| x.to_css()),
54
+ CssKind::Palette => CssPalette::parse(src).map(|x| x.to_css()),
55
+ CssKind::Color => CssColor::parse(src).map(|x| x.to_css()),
56
+ }
57
+ }
58
+
59
+ pub fn short_name(self, var_name: &str) -> String {
60
+ var_name
61
+ .strip_prefix(self.var_prefix())
62
+ .unwrap_or(var_name)
63
+ .to_owned()
64
+ }
65
+ }
66
+
67
+ /// The property name a whole-value `var(--psp-user--…)` reference
68
+ /// names, or `None` when `src` is not one.
69
+ pub fn parse_var_ref(src: &str) -> Option<&str> {
70
+ let inner = src.trim().strip_prefix("var(")?.strip_suffix(')')?.trim();
71
+ if !inner.starts_with(USER_VAR_PREFIX)
72
+ || inner.len() == USER_VAR_PREFIX.len()
73
+ || inner.contains(|c: char| c.is_whitespace() || c == ',' || c == ')')
74
+ {
75
+ return None;
76
+ }
77
+
78
+ Some(inner)
79
+ }
80
+
81
+ /// `var(name)`.
82
+ pub fn format_var_ref(name: &str) -> String {
83
+ format!("var({name})")
84
+ }
85
+
86
+ fn parse_kind_var(src: &str, kind: CssKind) -> Result<Option<String>, String> {
87
+ let Some(name) = parse_var_ref(src) else {
88
+ return Ok(None);
89
+ };
90
+
91
+ if name.starts_with(kind.var_prefix()) {
92
+ Ok(Some(name.to_owned()))
93
+ } else {
94
+ Err(format!(
95
+ "`{src}` is not a {} reference (expected `var({}…)`)",
96
+ kind_label(kind),
97
+ kind.var_prefix()
98
+ ))
99
+ }
100
+ }
101
+
102
+ fn kind_label(kind: CssKind) -> &'static str {
103
+ match kind {
104
+ CssKind::Gradient => "gradient",
105
+ CssKind::Palette => "palette",
106
+ CssKind::Color => "color",
107
+ }
108
+ }
109
+
110
+ fn hex_channel(src: &str) -> Result<u8, String> {
111
+ u8::from_str_radix(src, 16).map_err(|_| format!("invalid hex color `#{src}`"))
112
+ }
113
+
114
+ fn channel_from_token(token: &str) -> Result<u8, String> {
115
+ let token = token.trim();
116
+ let value = match token.strip_suffix('%') {
117
+ Some(pct) => {
118
+ pct.trim()
119
+ .parse::<f64>()
120
+ .map_err(|_| format!("invalid color channel `{token}`"))?
121
+ / 100.0
122
+ * 255.0
123
+ },
124
+ None => token
125
+ .parse::<f64>()
126
+ .map_err(|_| format!("invalid color channel `{token}`"))?,
127
+ };
128
+
129
+ Ok(value.round().clamp(0.0, 255.0) as u8)
130
+ }
131
+
132
+ /// A color literal — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`
133
+ /// or `rgba()` — normalized to lowercase `#rrggbb`.
134
+ pub fn normalize_css_color(src: &str) -> Result<String, String> {
135
+ let src = src.trim();
136
+ if let Some(hex) = src.strip_prefix('#') {
137
+ let (r, g, b) = match hex.len() {
138
+ 3 | 4 => {
139
+ let d = |i: usize| hex_channel(&hex[i..i + 1]).map(|x| x * 17);
140
+ (d(0)?, d(1)?, d(2)?)
141
+ },
142
+ 6 | 8 => (
143
+ hex_channel(&hex[0..2])?,
144
+ hex_channel(&hex[2..4])?,
145
+ hex_channel(&hex[4..6])?,
146
+ ),
147
+ _ => return Err(format!("invalid hex color `{src}`")),
148
+ };
149
+
150
+ return Ok(format!("#{r:02x}{g:02x}{b:02x}"));
151
+ }
152
+
153
+ let lower = src.to_ascii_lowercase();
154
+ let body = lower
155
+ .strip_prefix("rgba(")
156
+ .or_else(|| lower.strip_prefix("rgb("))
157
+ .and_then(|x| x.strip_suffix(')'))
158
+ .ok_or_else(|| format!("unsupported color `{src}`"))?;
159
+
160
+ let body = body.split('/').next().unwrap_or("");
161
+ let tokens: Vec<&str> = body
162
+ .split(|c: char| c == ',' || c.is_whitespace())
163
+ .filter(|x| !x.is_empty())
164
+ .collect();
165
+
166
+ if tokens.len() < 3 || tokens.len() > 4 {
167
+ return Err(format!("unsupported color `{src}`"));
168
+ }
169
+
170
+ Ok(format!(
171
+ "#{:02x}{:02x}{:02x}",
172
+ channel_from_token(tokens[0])?,
173
+ channel_from_token(tokens[1])?,
174
+ channel_from_token(tokens[2])?
175
+ ))
176
+ }
177
+
178
+ fn split_entries(body: &str) -> Vec<&str> {
179
+ let mut parts = vec![];
180
+ let mut depth = 0i32;
181
+ let mut start = 0;
182
+ for (i, ch) in body.char_indices() {
183
+ match ch {
184
+ '(' => depth += 1,
185
+ ')' => depth -= 1,
186
+ ',' if depth == 0 => {
187
+ parts.push(&body[start..i]);
188
+ start = i + 1;
189
+ },
190
+ _ => {},
191
+ }
192
+ }
193
+
194
+ parts.push(&body[start..]);
195
+ parts
196
+ }
197
+
198
+ fn is_direction_token(entry: &str) -> bool {
199
+ let entry = entry.trim().to_ascii_lowercase();
200
+ if entry.starts_with("to ") {
201
+ return true;
202
+ }
203
+
204
+ ["deg", "rad", "grad", "turn"].iter().any(|unit| {
205
+ entry
206
+ .strip_suffix(unit)
207
+ .map(|n| n.parse::<f64>().is_ok())
208
+ .unwrap_or(false)
209
+ })
210
+ }
211
+
212
+ fn tokenize_linear_gradient(src: &str) -> Result<Vec<(String, Option<f64>)>, String> {
213
+ let src = src.trim();
214
+ let lower = src.to_ascii_lowercase();
215
+ let body = lower
216
+ .strip_prefix("linear-gradient(")
217
+ .and_then(|x| x.strip_suffix(')'))
218
+ .ok_or_else(|| format!("expected `linear-gradient(…)`, got `{src}`"))?;
219
+
220
+ let entries = split_entries(body);
221
+ let mut out = vec![];
222
+ for (index, entry) in entries.iter().enumerate() {
223
+ let entry = entry.trim();
224
+ if entry.is_empty() {
225
+ return Err(format!("empty entry in `{src}`"));
226
+ }
227
+
228
+ if index == 0 && is_direction_token(entry) {
229
+ continue;
230
+ }
231
+
232
+ let (color, position) = match entry.rfind(|c: char| c.is_whitespace()) {
233
+ Some(at) if !entry[at..].contains(')') => {
234
+ let tail = entry[at..].trim();
235
+ if let Some(pct) = tail.strip_suffix('%') {
236
+ let value = pct
237
+ .parse::<f64>()
238
+ .map_err(|_| format!("invalid stop position `{tail}`"))?;
239
+ (entry[..at].trim(), Some(value / 100.0))
240
+ } else if tail.starts_with(|c: char| c.is_ascii_digit() || c == '.' || c == '-') {
241
+ return Err(format!(
242
+ "unsupported stop position `{tail}` (only `%` is accepted)"
243
+ ));
244
+ } else {
245
+ (entry, None)
246
+ }
247
+ },
248
+ _ => (entry, None),
249
+ };
250
+
251
+ out.push((normalize_css_color(color)?, position));
252
+ }
253
+
254
+ Ok(out)
255
+ }
256
+
257
+ fn format_percent(offset: f64) -> String {
258
+ let text = format!("{:.1}", offset * 100.0);
259
+ let text = text.strip_suffix(".0").unwrap_or(&text).to_owned();
260
+ format!("{text}%")
261
+ }
262
+
263
+ /// The canonical gradient string for `stops`: `linear-gradient(to
264
+ /// right, #rrggbb P%, …)` with every position explicit.
265
+ pub fn gradient_to_css(stops: &[GradientStopSpec]) -> String {
266
+ let body = canonicalize_gradient_stops(stops.to_vec())
267
+ .iter()
268
+ .map(|stop| format!("{} {}", stop.color, format_percent(stop.offset)))
269
+ .collect::<Vec<_>>()
270
+ .join(", ");
271
+
272
+ format!("linear-gradient(to right, {body})")
273
+ }
274
+
275
+ /// The canonical palette string for `colors`: `linear-gradient(to
276
+ /// right, #rrggbb, …)` with no positions.
277
+ pub fn palette_to_css(colors: &[String]) -> String {
278
+ format!("linear-gradient(to right, {})", colors.join(", "))
279
+ }
280
+
281
+ /// A serialized [`ControlSpec::GradientStops`] value.
282
+ ///
283
+ /// [`ControlSpec::GradientStops`]: super::ControlSpec::GradientStops
284
+ #[derive(Clone, Debug, PartialEq)]
285
+ pub enum CssGradient {
286
+ /// Canonical stops (sorted, rounded), length ≥ 2.
287
+ Literal(Vec<GradientStopSpec>),
288
+ /// A `--psp-user--gradient-…` property name.
289
+ Var(String),
290
+ }
291
+
292
+ impl CssGradient {
293
+ /// Parse a gradient string of at least 2 stops, with optional
294
+ /// positions filled by the CSS implicit rules.
295
+ pub fn parse(src: &str) -> Result<Self, String> {
296
+ if let Some(name) = parse_kind_var(src, CssKind::Gradient)? {
297
+ return Ok(CssGradient::Var(name));
298
+ }
299
+
300
+ let entries = tokenize_linear_gradient(src)?;
301
+ if entries.len() < 2 {
302
+ return Err(format!("a gradient needs at least 2 stops: `{src}`"));
303
+ }
304
+
305
+ let mut offsets: Vec<Option<f64>> = entries.iter().map(|(_, p)| *p).collect();
306
+ let last = offsets.len() - 1;
307
+ offsets[0].get_or_insert(0.0);
308
+ offsets[last].get_or_insert(1.0);
309
+ let mut i = 1;
310
+ while i < last {
311
+ if offsets[i].is_some() {
312
+ i += 1;
313
+ continue;
314
+ }
315
+
316
+ let mut j = i + 1;
317
+ while offsets[j].is_none() {
318
+ j += 1;
319
+ }
320
+
321
+ let before = offsets[i - 1].unwrap();
322
+ let after = offsets[j].unwrap();
323
+ let span = (j - (i - 1)) as f64;
324
+ for (k, slot) in offsets.iter_mut().enumerate().take(j).skip(i) {
325
+ *slot = Some(before + ((k - (i - 1)) as f64 / span) * (after - before));
326
+ }
327
+
328
+ i = j;
329
+ }
330
+
331
+ let stops = entries
332
+ .into_iter()
333
+ .zip(offsets)
334
+ .map(|((color, _), offset)| GradientStopSpec {
335
+ color,
336
+ offset: offset.unwrap(),
337
+ })
338
+ .collect();
339
+
340
+ Ok(CssGradient::Literal(canonicalize_gradient_stops(stops)))
341
+ }
342
+
343
+ pub fn to_css(&self) -> String {
344
+ match self {
345
+ CssGradient::Literal(stops) => gradient_to_css(stops),
346
+ CssGradient::Var(name) => format_var_ref(name),
347
+ }
348
+ }
349
+ }
350
+
351
+ /// A serialized [`ControlSpec::Palette`] value.
352
+ ///
353
+ /// [`ControlSpec::Palette`]: super::ControlSpec::Palette
354
+ #[derive(Clone, Debug, PartialEq)]
355
+ pub enum CssPalette {
356
+ /// Ordered `#rrggbb` entries, length ≥ 1.
357
+ Literal(Vec<String>),
358
+ /// A `--psp-user--palette-…` property name.
359
+ Var(String),
360
+ }
361
+
362
+ impl CssPalette {
363
+ /// Parse a palette string of at least 1 entry, with positions
364
+ /// forbidden.
365
+ pub fn parse(src: &str) -> Result<Self, String> {
366
+ if let Some(name) = parse_kind_var(src, CssKind::Palette)? {
367
+ return Ok(CssPalette::Var(name));
368
+ }
369
+
370
+ let entries = tokenize_linear_gradient(src)?;
371
+ if entries.is_empty() {
372
+ return Err(format!("a palette needs at least 1 color: `{src}`"));
373
+ }
374
+
375
+ let mut colors = Vec::with_capacity(entries.len());
376
+ for (color, position) in entries {
377
+ if position.is_some() {
378
+ return Err(format!(
379
+ "palette entries must not carry positions (a positioned `linear-gradient()` \
380
+ is a gradient, not a palette): `{src}`"
381
+ ));
382
+ }
383
+
384
+ colors.push(color);
385
+ }
386
+
387
+ Ok(CssPalette::Literal(colors))
388
+ }
389
+
390
+ pub fn to_css(&self) -> String {
391
+ match self {
392
+ CssPalette::Literal(colors) => palette_to_css(colors),
393
+ CssPalette::Var(name) => format_var_ref(name),
394
+ }
395
+ }
396
+ }
397
+
398
+ /// A serialized [`ControlSpec::Color`] value.
399
+ ///
400
+ /// [`ControlSpec::Color`]: super::ControlSpec::Color
401
+ #[derive(Clone, Debug, PartialEq)]
402
+ pub enum CssColor {
403
+ /// Lowercase `#rrggbb`.
404
+ Literal(String),
405
+ /// A `--psp-user--color-…` property name.
406
+ Var(String),
407
+ }
408
+
409
+ impl CssColor {
410
+ pub fn parse(src: &str) -> Result<Self, String> {
411
+ if let Some(name) = parse_kind_var(src, CssKind::Color)? {
412
+ return Ok(CssColor::Var(name));
413
+ }
414
+
415
+ normalize_css_color(src).map(CssColor::Literal)
416
+ }
417
+
418
+ pub fn to_css(&self) -> String {
419
+ match self {
420
+ CssColor::Literal(color) => color.clone(),
421
+ CssColor::Var(name) => format_var_ref(name),
422
+ }
423
+ }
424
+ }
425
+
426
+ pub fn canonicalize_css_gradient(src: &str) -> Result<String, String> {
427
+ CssKind::Gradient.canonicalize(src)
428
+ }
429
+
430
+ pub fn canonicalize_css_palette(src: &str) -> Result<String, String> {
431
+ CssKind::Palette.canonicalize(src)
432
+ }
433
+
434
+ pub fn canonicalize_css_color(src: &str) -> Result<String, String> {
435
+ CssKind::Color.canonicalize(src)
436
+ }
437
+
438
+ /// One CSS literal stored in a panel's `columns_config`: its column,
439
+ /// key, schema kind and canonical value — the unit of "in use".
440
+ #[derive(Clone, Debug, PartialEq)]
441
+ pub struct CssLiteralUse {
442
+ pub column: String,
443
+ pub key: String,
444
+ pub kind: CssKind,
445
+ pub literal: String,
446
+ }
447
+
448
+ /// The workspace palette: `restored` unioned with every in-use
449
+ /// literal, each named deterministically.
450
+ pub fn assign_palette_names(
451
+ restored: &BTreeMap<String, String>,
452
+ host: &[NamedValue],
453
+ in_use: &[(CssKind, String)],
454
+ is_taken: &dyn Fn(&str) -> bool,
455
+ ) -> BTreeMap<String, String> {
456
+ let mut set = restored.clone();
457
+ for (kind, literal) in in_use {
458
+ if palette_name_for(&set, *kind, literal).is_some() {
459
+ continue;
460
+ }
461
+
462
+ let name = host
463
+ .iter()
464
+ .find(|entry| entry.name.starts_with(kind.var_prefix()) && entry.value == *literal)
465
+ .map(|entry| entry.name.clone())
466
+ .unwrap_or_else(|| {
467
+ (1..)
468
+ .map(|n| format!("{}{n}", kind.var_prefix()))
469
+ .find(|name| !set.contains_key(name) && !is_taken(name))
470
+ .unwrap()
471
+ });
472
+
473
+ set.insert(name, literal.clone());
474
+ }
475
+
476
+ set
477
+ }
478
+
479
+ /// The name under which `set` holds `literal` as a value of `kind`.
480
+ pub fn palette_name_for(
481
+ set: &BTreeMap<String, String>,
482
+ kind: CssKind,
483
+ literal: &str,
484
+ ) -> Option<String> {
485
+ set.iter()
486
+ .find(|(name, value)| name.starts_with(kind.var_prefix()) && *value == literal)
487
+ .map(|(name, _)| name.clone())
488
+ }
489
+
490
+ /// Replace every whole-value `var(--psp-user--…)` string in one
491
+ /// column's config entry with the literal `lookup` resolves it to,
492
+ /// removing and reporting keys that do not resolve.
493
+ pub fn resolve_css_refs(
494
+ entry: &mut serde_json::Map<String, Value>,
495
+ lookup: &dyn Fn(&str) -> Option<String>,
496
+ ) -> Vec<(String, String)> {
497
+ let mut dropped = vec![];
498
+ let refs: Vec<(String, String)> = entry
499
+ .iter()
500
+ .filter_map(|(key, value)| {
501
+ let name = parse_var_ref(value.as_str()?)?;
502
+ Some((key.clone(), name.to_owned()))
503
+ })
504
+ .collect();
505
+
506
+ for (key, name) in refs {
507
+ let resolved = CssKind::of_var(&name)
508
+ .and_then(|kind| lookup(&name).and_then(|raw| kind.canonicalize(&raw).ok()));
509
+
510
+ match resolved {
511
+ Some(literal) => {
512
+ entry.insert(key, Value::String(literal));
513
+ },
514
+ None => {
515
+ entry.remove(&key);
516
+ dropped.push((key, name));
517
+ },
518
+ }
519
+ }
520
+
521
+ dropped
522
+ }
523
+
524
+ #[cfg(test)]
525
+ mod tests {
526
+ use serde_json::json;
527
+
528
+ use super::*;
529
+
530
+ fn stop(color: &str, offset: f64) -> GradientStopSpec {
531
+ GradientStopSpec {
532
+ color: color.to_owned(),
533
+ offset,
534
+ }
535
+ }
536
+
537
+ #[test]
538
+ fn colors_normalize_to_lowercase_hex() {
539
+ assert_eq!(normalize_css_color("#ABC").unwrap(), "#aabbcc");
540
+ assert_eq!(normalize_css_color("#abcd").unwrap(), "#aabbcc");
541
+ assert_eq!(normalize_css_color(" #FF7F0E ").unwrap(), "#ff7f0e");
542
+ assert_eq!(normalize_css_color("#ff7f0e80").unwrap(), "#ff7f0e");
543
+ assert_eq!(normalize_css_color("rgb(255, 127, 14)").unwrap(), "#ff7f0e");
544
+ assert_eq!(
545
+ normalize_css_color("rgba(255,127,14,0.5)").unwrap(),
546
+ "#ff7f0e"
547
+ );
548
+ assert_eq!(
549
+ normalize_css_color("rgb(255 127 14 / 50%)").unwrap(),
550
+ "#ff7f0e"
551
+ );
552
+ assert_eq!(
553
+ normalize_css_color("rgb(100%, 0%, 50%)").unwrap(),
554
+ "#ff0080"
555
+ );
556
+ assert_eq!(normalize_css_color("rgb(300, -5, 14)").unwrap(), "#ff000e");
557
+ assert!(normalize_css_color("red").is_err());
558
+ assert!(normalize_css_color("#12345").is_err());
559
+ assert!(normalize_css_color("hsl(0, 100%, 50%)").is_err());
560
+ assert!(normalize_css_color("#gggggg").is_err());
561
+ assert!(normalize_css_color("").is_err());
562
+ }
563
+
564
+ #[test]
565
+ fn var_refs_are_whole_value_and_namespaced() {
566
+ assert_eq!(
567
+ parse_var_ref("var(--psp-user--gradient-1)"),
568
+ Some("--psp-user--gradient-1")
569
+ );
570
+ assert_eq!(
571
+ parse_var_ref(" var( --psp-user--color-hot ) "),
572
+ Some("--psp-user--color-hot")
573
+ );
574
+ assert_eq!(parse_var_ref("var(--psp-charts--gradient)"), None);
575
+ assert_eq!(parse_var_ref("var(--psp-user--)"), None);
576
+ assert_eq!(parse_var_ref("var(--psp-user--gradient-1, red)"), None);
577
+ assert_eq!(parse_var_ref("#ff0000"), None);
578
+ assert_eq!(
579
+ parse_var_ref("linear-gradient(var(--psp-user--color-1), #fff)"),
580
+ None
581
+ );
582
+ }
583
+
584
+ #[test]
585
+ fn kind_is_carried_by_the_reference_prefix() {
586
+ assert_eq!(
587
+ CssKind::of_var("--psp-user--gradient-1"),
588
+ Some(CssKind::Gradient)
589
+ );
590
+ assert_eq!(
591
+ CssKind::of_var("--psp-user--palette-warm"),
592
+ Some(CssKind::Palette)
593
+ );
594
+ assert_eq!(CssKind::of_var("--psp-user--color-1"), Some(CssKind::Color));
595
+ assert_eq!(CssKind::of_var("--psp-user--other-1"), None);
596
+ assert_eq!(
597
+ CssKind::Palette.short_name("--psp-user--palette-warm"),
598
+ "warm"
599
+ );
600
+
601
+ assert_eq!(
602
+ CssGradient::parse("var(--psp-user--gradient-1)").unwrap(),
603
+ CssGradient::Var("--psp-user--gradient-1".to_owned())
604
+ );
605
+ assert!(CssGradient::parse("var(--psp-user--palette-1)").is_err());
606
+ assert!(CssPalette::parse("var(--psp-user--gradient-1)").is_err());
607
+ assert!(CssColor::parse("var(--psp-user--palette-1)").is_err());
608
+ assert_eq!(
609
+ CssColor::parse("var(--psp-user--color-1)")
610
+ .unwrap()
611
+ .to_css(),
612
+ "var(--psp-user--color-1)"
613
+ );
614
+
615
+ assert_eq!(
616
+ canonicalize_css_palette("var(--psp-user--palette-1)").unwrap(),
617
+ "var(--psp-user--palette-1)"
618
+ );
619
+ }
620
+
621
+ #[test]
622
+ fn gradient_round_trip_is_canonical_and_idempotent() {
623
+ let src = "linear-gradient(to right, #0366d6 0%, #ffffff 33.3%, #ff7f0e 100%)";
624
+ let canon = canonicalize_css_gradient(src).unwrap();
625
+ assert_eq!(canon, src);
626
+ assert_eq!(canonicalize_css_gradient(&canon).unwrap(), canon);
627
+
628
+ let CssGradient::Literal(stops) = CssGradient::parse(src).unwrap() else {
629
+ panic!("expected literal");
630
+ };
631
+
632
+ assert_eq!(stops, vec![
633
+ stop("#0366d6", 0.0),
634
+ stop("#ffffff", 0.333),
635
+ stop("#ff7f0e", 1.0),
636
+ ]);
637
+
638
+ let css = gradient_to_css(&[
639
+ stop("#222222", 1.7),
640
+ stop("#111111", 1.0 / 3.0),
641
+ stop("#000000", -0.5),
642
+ ]);
643
+
644
+ assert_eq!(
645
+ css,
646
+ "linear-gradient(to right, #000000 0%, #111111 33.3%, #222222 100%)"
647
+ );
648
+
649
+ assert_eq!(format_percent(0.375), "37.5%");
650
+ assert_eq!(format_percent(0.1), "10%");
651
+ assert_eq!(format_percent(1.0), "100%");
652
+ }
653
+
654
+ #[test]
655
+ fn gradient_accepts_loose_input() {
656
+ assert_eq!(
657
+ canonicalize_css_gradient("linear-gradient(#0366d6 0%, #ff7f0e 100%)").unwrap(),
658
+ "linear-gradient(to right, #0366d6 0%, #ff7f0e 100%)"
659
+ );
660
+
661
+ assert_eq!(
662
+ canonicalize_css_gradient("LINEAR-GRADIENT(90deg, RGB(3, 102, 214), #FFF, #ff7f0e)")
663
+ .unwrap(),
664
+ "linear-gradient(to right, #0366d6 0%, #ffffff 50%, #ff7f0e 100%)"
665
+ );
666
+
667
+ assert_eq!(
668
+ canonicalize_css_gradient(
669
+ "linear-gradient(to bottom, #000 10%, #111, #222, #333 70%, #444)"
670
+ )
671
+ .unwrap(),
672
+ "linear-gradient(to right, #000000 10%, #111111 30%, #222222 50%, #333333 70%, \
673
+ #444444 100%)"
674
+ );
675
+
676
+ assert_eq!(
677
+ canonicalize_css_gradient("linear-gradient(#fff 120%, #000 -5%)").unwrap(),
678
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)"
679
+ );
680
+ }
681
+
682
+ #[test]
683
+ fn gradient_rejects_malformed_input() {
684
+ assert!(canonicalize_css_gradient("#ff0000").is_err());
685
+ assert!(canonicalize_css_gradient("linear-gradient(#ff0000)").is_err());
686
+ assert!(canonicalize_css_gradient("linear-gradient(#ff0000 0px, #fff 10px)").is_err());
687
+ assert!(canonicalize_css_gradient("linear-gradient(red, blue)").is_err());
688
+ assert!(canonicalize_css_gradient("radial-gradient(#000, #fff)").is_err());
689
+ assert!(canonicalize_css_gradient("linear-gradient(#000, , #fff)").is_err());
690
+ assert!(canonicalize_css_gradient("").is_err());
691
+ }
692
+
693
+ #[test]
694
+ fn palette_round_trip_and_rules() {
695
+ let src = "linear-gradient(to right, #0366d6, #ff7f0e, #2ca02c)";
696
+ assert_eq!(canonicalize_css_palette(src).unwrap(), src);
697
+ assert_eq!(
698
+ CssPalette::parse(src).unwrap(),
699
+ CssPalette::Literal(vec![
700
+ "#0366d6".to_owned(),
701
+ "#ff7f0e".to_owned(),
702
+ "#2ca02c".to_owned()
703
+ ])
704
+ );
705
+
706
+ assert_eq!(
707
+ canonicalize_css_palette("linear-gradient(90deg, rgb(3,102,214), #FFF)").unwrap(),
708
+ "linear-gradient(to right, #0366d6, #ffffff)"
709
+ );
710
+
711
+ assert_eq!(
712
+ canonicalize_css_palette("linear-gradient(#abc)").unwrap(),
713
+ "linear-gradient(to right, #aabbcc)"
714
+ );
715
+
716
+ assert_eq!(
717
+ palette_to_css(&["#000000".to_owned(), "#ffffff".to_owned()]),
718
+ "linear-gradient(to right, #000000, #ffffff)"
719
+ );
720
+ }
721
+
722
+ #[test]
723
+ fn palette_rejects_positions() {
724
+ let gradient = gradient_to_css(&[stop("#000000", 0.0), stop("#ffffff", 1.0)]);
725
+ assert!(CssPalette::parse(&gradient).is_err());
726
+ assert!(canonicalize_css_palette("linear-gradient(#000, #fff 50%)").is_err());
727
+ assert!(canonicalize_css_palette("linear-gradient()").is_err());
728
+ assert!(canonicalize_css_palette("#000000").is_err());
729
+
730
+ let palette = palette_to_css(&["#000000".to_owned(), "#ffffff".to_owned()]);
731
+ assert_eq!(
732
+ canonicalize_css_gradient(&palette).unwrap(),
733
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)"
734
+ );
735
+ }
736
+
737
+ #[test]
738
+ fn color_canonicalizes_literals_and_passes_refs() {
739
+ assert_eq!(canonicalize_css_color("#ABC").unwrap(), "#aabbcc");
740
+ assert_eq!(
741
+ canonicalize_css_color("var(--psp-user--color-hot)").unwrap(),
742
+ "var(--psp-user--color-hot)"
743
+ );
744
+ assert!(canonicalize_css_color("var(--psp-user--gradient-1)").is_err());
745
+ assert!(canonicalize_css_color("linear-gradient(#000, #fff)").is_err());
746
+ }
747
+
748
+ #[test]
749
+ fn palette_set_unions_restored_and_in_use_with_stable_names() {
750
+ let mut restored = BTreeMap::new();
751
+ restored.insert(
752
+ "--psp-user--gradient-heat".to_owned(),
753
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
754
+ );
755
+ restored.insert("--psp-user--color-unused".to_owned(), "#123456".to_owned());
756
+ let host = vec![NamedValue {
757
+ name: "--psp-user--palette-1".to_owned(),
758
+ value: "linear-gradient(to right, #ff0000, #00ff00)".to_owned(),
759
+ }];
760
+ let taken =
761
+ |name: &str| name == "--psp-user--gradient-1" || name == "--psp-user--palette-1";
762
+ let in_use = vec![
763
+ (
764
+ CssKind::Gradient,
765
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
766
+ ),
767
+ (
768
+ CssKind::Gradient,
769
+ "linear-gradient(to right, #111111 0%, #222222 100%)".to_owned(),
770
+ ),
771
+ (
772
+ CssKind::Gradient,
773
+ "linear-gradient(to right, #111111 0%, #222222 100%)".to_owned(),
774
+ ),
775
+ (
776
+ CssKind::Palette,
777
+ "linear-gradient(to right, #ff0000, #00ff00)".to_owned(),
778
+ ),
779
+ (CssKind::Color, "#abcdef".to_owned()),
780
+ ];
781
+
782
+ let set = assign_palette_names(&restored, &host, &in_use, &taken);
783
+ assert_eq!(
784
+ set.iter()
785
+ .map(|(k, v)| (k.as_str(), v.as_str()))
786
+ .collect::<Vec<_>>(),
787
+ vec![
788
+ ("--psp-user--color-1", "#abcdef"),
789
+ ("--psp-user--color-unused", "#123456"),
790
+ (
791
+ "--psp-user--gradient-2",
792
+ "linear-gradient(to right, #111111 0%, #222222 100%)"
793
+ ),
794
+ (
795
+ "--psp-user--gradient-heat",
796
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)"
797
+ ),
798
+ (
799
+ "--psp-user--palette-1",
800
+ "linear-gradient(to right, #ff0000, #00ff00)"
801
+ ),
802
+ ]
803
+ );
804
+
805
+ assert_eq!(
806
+ palette_name_for(
807
+ &set,
808
+ CssKind::Gradient,
809
+ "linear-gradient(to right, #111111 0%, #222222 100%)"
810
+ )
811
+ .as_deref(),
812
+ Some("--psp-user--gradient-2")
813
+ );
814
+ assert_eq!(palette_name_for(&set, CssKind::Gradient, "#abcdef"), None);
815
+
816
+ assert_eq!(assign_palette_names(&set, &host, &in_use, &taken), set);
817
+ }
818
+
819
+ #[test]
820
+ fn resolver_inlines_known_refs_and_drops_the_rest() {
821
+ let host = |name: &str| -> Option<String> {
822
+ match name {
823
+ "--psp-user--gradient-1" => Some("linear-gradient(#000, #fff)".to_owned()),
824
+ "--psp-user--palette-warm" => Some("linear-gradient(#f00, #ff0)".to_owned()),
825
+ "--psp-user--color-hot" => Some("rgb(255, 0, 0)".to_owned()),
826
+ "--psp-user--palette-bad" => Some("linear-gradient(#000 0%, #fff 100%)".to_owned()),
827
+ "--psp-user--color-empty" => Some(" ".to_owned()),
828
+ _ => None,
829
+ }
830
+ };
831
+
832
+ let mut entry = json!({
833
+ "gradient": "var(--psp-user--gradient-1)",
834
+ "palette": "var(--psp-user--palette-warm)",
835
+ "color": "var(--psp-user--color-hot)",
836
+ "bad_palette": "var(--psp-user--palette-bad)",
837
+ "missing": "var(--psp-user--gradient-missing)",
838
+ "empty": "var(--psp-user--color-empty)",
839
+ "unknown_kind": "var(--psp-user--other-1)",
840
+ "literal": "#123456",
841
+ "number": 3,
842
+ })
843
+ .as_object()
844
+ .unwrap()
845
+ .clone();
846
+
847
+ let mut dropped = resolve_css_refs(&mut entry, &host);
848
+ dropped.sort();
849
+ assert_eq!(dropped, vec![
850
+ (
851
+ "bad_palette".to_owned(),
852
+ "--psp-user--palette-bad".to_owned()
853
+ ),
854
+ ("empty".to_owned(), "--psp-user--color-empty".to_owned()),
855
+ (
856
+ "missing".to_owned(),
857
+ "--psp-user--gradient-missing".to_owned()
858
+ ),
859
+ ("unknown_kind".to_owned(), "--psp-user--other-1".to_owned()),
860
+ ]);
861
+
862
+ assert_eq!(
863
+ entry,
864
+ json!({
865
+ "gradient": "linear-gradient(to right, #000000 0%, #ffffff 100%)",
866
+ "palette": "linear-gradient(to right, #ff0000, #ffff00)",
867
+ "color": "#ff0000",
868
+ "literal": "#123456",
869
+ "number": 3,
870
+ })
871
+ .as_object()
872
+ .unwrap()
873
+ .clone()
874
+ );
875
+
876
+ assert!(entry.values().all(|v| {
877
+ v.as_str()
878
+ .map(|s| parse_var_ref(s).is_none())
879
+ .unwrap_or(true)
880
+ }));
881
+ }
882
+ }