@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
@@ -41,10 +41,6 @@ impl Default for DatetimeFormatType {
41
41
  }
42
42
 
43
43
  impl DatetimeFormatType {
44
- fn is_simple(&self) -> bool {
45
- self == &Self::Simple(SimpleDatetimeStyleConfig::default())
46
- }
47
-
48
44
  pub fn time_zone(&self) -> &Option<String> {
49
45
  match self {
50
46
  DatetimeFormatType::Custom(x) => &x.time_zone,
@@ -62,12 +58,13 @@ impl DatetimeFormatType {
62
58
 
63
59
  /// A model for the JSON serialized style configuration for a column of type
64
60
  /// `datetime`.
65
- #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
61
+ #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, TS)]
66
62
  // #[derive(WasmDescribe!, FromWasmAbi!)]
67
63
  pub struct DatetimeColumnStyleConfig {
68
64
  #[serde(default)]
69
- #[serde(skip_serializing_if = "DatetimeFormatType::is_simple")]
70
- pub date_format: DatetimeFormatType,
65
+ #[serde(skip_serializing_if = "Option::is_none")]
66
+ #[ts(optional, as = "Option<_>")]
67
+ pub date_format: Option<DatetimeFormatType>,
71
68
 
72
69
  #[serde(default)]
73
70
  #[serde(skip_serializing_if = "DatetimeColorMode::is_none")]
@@ -78,17 +75,3 @@ pub struct DatetimeColumnStyleConfig {
78
75
  #[ts(skip)]
79
76
  pub color: Option<String>,
80
77
  }
81
-
82
- impl Default for DatetimeColumnStyleConfig {
83
- fn default() -> Self {
84
- Self {
85
- date_format: DatetimeFormatType::Simple(SimpleDatetimeStyleConfig {
86
- time_zone: Default::default(),
87
- date_style: SimpleDatetimeFormat::Short,
88
- time_style: SimpleDatetimeFormat::Medium,
89
- }),
90
- datetime_color_mode: Default::default(),
91
- color: Default::default(),
92
- }
93
- }
94
- }
@@ -15,6 +15,7 @@
15
15
 
16
16
  mod column_config_schema;
17
17
  mod columns_config;
18
+ mod css_scale;
18
19
  mod datetime_column_style;
19
20
  mod export_method;
20
21
  mod kvpair;
@@ -29,6 +30,7 @@ mod workspace_config;
29
30
 
30
31
  pub use column_config_schema::*;
31
32
  pub use columns_config::*;
33
+ pub use css_scale::*;
32
34
  pub use datetime_column_style::*;
33
35
  pub use export_method::*;
34
36
  pub use number_series_style::*;
@@ -255,13 +255,106 @@ pub struct CustomNumberFormatConfig {
255
255
  pub sign_display: Option<SignDisplay>,
256
256
  }
257
257
 
258
+ /// The active plugin's `NumberFormat` default overlaid on the built-in
259
+ /// `number_format` values.
260
+ #[derive(Clone, Debug, PartialEq)]
261
+ pub struct NumberFormatDefaults {
262
+ pub is_float: bool,
263
+ pub style: NumberFormatStyle,
264
+ pub notation: Notation,
265
+ pub minimum_integer_digits: f64,
266
+ pub fraction: (f64, f64),
267
+ pub significant: (f64, f64),
268
+ pub rounding_priority: RoundingPriority,
269
+ pub rounding_mode: RoundingMode,
270
+ pub trailing_zero_display: TrailingZeroDisplay,
271
+ pub use_grouping: UseGrouping,
272
+ pub sign_display: SignDisplay,
273
+ }
274
+
275
+ impl NumberFormatDefaults {
276
+ pub fn builtin(is_float: bool) -> Self {
277
+ Self {
278
+ is_float,
279
+ style: NumberFormatStyle::default(),
280
+ notation: Notation::default(),
281
+ minimum_integer_digits: 1.,
282
+ fraction: if is_float { (2., 2.) } else { (0., 0.) },
283
+ significant: (1., 21.),
284
+ rounding_priority: RoundingPriority::default(),
285
+ rounding_mode: RoundingMode::default(),
286
+ trailing_zero_display: TrailingZeroDisplay::default(),
287
+ use_grouping: UseGrouping::default(),
288
+ sign_display: SignDisplay::default(),
289
+ }
290
+ }
291
+
292
+ /// Overlay a plugin-declared partial default onto [`Self::builtin`].
293
+ pub fn resolve(spec_default: Option<&CustomNumberFormatConfig>, is_float: bool) -> Self {
294
+ let mut out = Self::builtin(is_float);
295
+ let Some(d) = spec_default else {
296
+ return out;
297
+ };
298
+
299
+ if let Some(style) = &d._style {
300
+ out.style = style.clone();
301
+ }
302
+
303
+ if let Some(notation) = &d._notation {
304
+ out.notation = notation.clone();
305
+ }
306
+
307
+ if let Some(v) = d.minimum_integer_digits {
308
+ out.minimum_integer_digits = v;
309
+ }
310
+
311
+ if let Some(v) = d.minimum_fraction_digits {
312
+ out.fraction.0 = v;
313
+ }
314
+
315
+ if let Some(v) = d.maximum_fraction_digits {
316
+ out.fraction.1 = v;
317
+ }
318
+
319
+ if let Some(v) = d.minimum_significant_digits {
320
+ out.significant.0 = v;
321
+ }
322
+
323
+ if let Some(v) = d.maximum_significant_digits {
324
+ out.significant.1 = v;
325
+ }
326
+
327
+ if let Some(v) = d.rounding_priority {
328
+ out.rounding_priority = v;
329
+ }
330
+
331
+ if let Some(v) = d.rounding_mode {
332
+ out.rounding_mode = v;
333
+ }
334
+
335
+ if let Some(v) = d.trailing_zero_display {
336
+ out.trailing_zero_display = v;
337
+ }
338
+
339
+ if let Some(v) = d.use_grouping {
340
+ out.use_grouping = v;
341
+ }
342
+
343
+ if let Some(v) = d.sign_display {
344
+ out.sign_display = v;
345
+ }
346
+
347
+ out
348
+ }
349
+ }
350
+
258
351
  impl CustomNumberFormatConfig {
259
- pub fn filter_default(self, is_float: bool) -> Self {
260
- let (frac_min, frac_max) = if is_float { (2., 2.) } else { (0., 0.) };
352
+ pub fn filter_default(self, defaults: &NumberFormatDefaults) -> Self {
353
+ let (frac_min, frac_max) = defaults.fraction;
261
354
  let rounding_increment = self.rounding_increment;
262
355
  let use_grouping = self
263
356
  .use_grouping
264
- .filter(|val| *val != UseGrouping::default());
357
+ .filter(|val| *val != defaults.use_grouping);
265
358
 
266
359
  let mut minimum_fraction_digits =
267
360
  self.minimum_fraction_digits.filter(|val| *val != frac_min);
@@ -269,7 +362,7 @@ impl CustomNumberFormatConfig {
269
362
  let mut maximum_fraction_digits =
270
363
  self.maximum_fraction_digits.filter(|val| *val != frac_max);
271
364
 
272
- let mut show_frac = is_float
365
+ let mut show_frac = defaults.is_float
273
366
  && (minimum_fraction_digits.is_some()
274
367
  || maximum_fraction_digits.is_some()
275
368
  || use_grouping.is_some()
@@ -277,7 +370,7 @@ impl CustomNumberFormatConfig {
277
370
  self._style,
278
371
  Some(NumberFormatStyle::Percent | NumberFormatStyle::Unit(_))
279
372
  ))
280
- || !is_float && matches!(self._style, Some(NumberFormatStyle::Currency(_)));
373
+ || !defaults.is_float && matches!(self._style, Some(NumberFormatStyle::Currency(_)));
281
374
 
282
375
  // Rounding increment does not work unless `minimum_fraction_digits`
283
376
  // and `maximum_fraction_digits` are set to 0.
@@ -287,39 +380,139 @@ impl CustomNumberFormatConfig {
287
380
  maximum_fraction_digits = Some(0.);
288
381
  }
289
382
 
290
- let minimum_significant_digits = self.minimum_significant_digits.filter(|val| *val != 1.);
291
- let maximum_significant_digits = self.maximum_significant_digits.filter(|val| *val != 21.);
383
+ let minimum_significant_digits = self
384
+ .minimum_significant_digits
385
+ .filter(|val| *val != defaults.significant.0);
386
+
387
+ let maximum_significant_digits = self
388
+ .maximum_significant_digits
389
+ .filter(|val| *val != defaults.significant.1);
390
+
292
391
  let show_sig = minimum_significant_digits.is_some() || maximum_significant_digits.is_some();
293
392
  Self {
294
- _style: self
295
- ._style
296
- .filter(|style| !matches!(style, NumberFormatStyle::Decimal)),
297
- minimum_integer_digits: self.minimum_integer_digits.filter(|val| *val != 1.),
393
+ _style: self._style.filter(|style| *style != defaults.style),
394
+ minimum_integer_digits: self
395
+ .minimum_integer_digits
396
+ .filter(|val| *val != defaults.minimum_integer_digits),
298
397
  minimum_fraction_digits: show_frac
299
398
  .then_some(minimum_fraction_digits.unwrap_or(frac_min)),
300
399
  maximum_fraction_digits: show_frac
301
400
  .then_some(maximum_fraction_digits.unwrap_or(frac_max)),
302
401
  minimum_significant_digits: show_sig
303
- .then_some(minimum_significant_digits.unwrap_or(1.)),
402
+ .then_some(minimum_significant_digits.unwrap_or(defaults.significant.0)),
304
403
  maximum_significant_digits: show_sig
305
- .then_some(minimum_significant_digits.unwrap_or(21.)),
404
+ .then_some(maximum_significant_digits.unwrap_or(defaults.significant.1)),
306
405
  rounding_priority: self
307
406
  .rounding_priority
308
- .filter(|val| *val != RoundingPriority::default()),
407
+ .filter(|val| *val != defaults.rounding_priority),
309
408
  rounding_increment,
310
409
  rounding_mode: self
311
410
  .rounding_mode
312
- .filter(|val| *val != RoundingMode::default()),
411
+ .filter(|val| *val != defaults.rounding_mode),
313
412
  trailing_zero_display: self
314
413
  .trailing_zero_display
315
- .filter(|val| *val != TrailingZeroDisplay::default()),
414
+ .filter(|val| *val != defaults.trailing_zero_display),
316
415
  _notation: self
317
416
  ._notation
318
- .filter(|notation| !matches!(notation, Notation::Standard)),
417
+ .filter(|notation| *notation != defaults.notation),
319
418
  use_grouping,
320
419
  sign_display: self
321
420
  .sign_display
322
- .filter(|val| *val != SignDisplay::default()),
421
+ .filter(|val| *val != defaults.sign_display),
422
+ }
423
+ }
424
+ }
425
+
426
+ #[cfg(test)]
427
+ mod tests {
428
+ use super::*;
429
+
430
+ fn sig(min: f64, max: f64) -> CustomNumberFormatConfig {
431
+ CustomNumberFormatConfig {
432
+ minimum_significant_digits: Some(min),
433
+ maximum_significant_digits: Some(max),
434
+ ..Default::default()
323
435
  }
324
436
  }
437
+
438
+ #[test]
439
+ fn resolve_overlays_partial_default_on_builtin() {
440
+ let spec = CustomNumberFormatConfig {
441
+ _notation: Some(Notation::Compact(CompactDisplay::Short)),
442
+ minimum_fraction_digits: Some(0.),
443
+ maximum_fraction_digits: Some(1.),
444
+ ..Default::default()
445
+ };
446
+
447
+ let defaults = NumberFormatDefaults::resolve(Some(&spec), true);
448
+ assert_eq!(defaults.notation, Notation::Compact(CompactDisplay::Short));
449
+ assert_eq!(defaults.fraction, (0., 1.));
450
+ assert_eq!(defaults.style, NumberFormatStyle::Decimal);
451
+ assert_eq!(defaults.significant, (1., 21.));
452
+ assert_eq!(
453
+ NumberFormatDefaults::resolve(None, true),
454
+ NumberFormatDefaults::builtin(true)
455
+ );
456
+ }
457
+
458
+ #[test]
459
+ fn filter_preserves_max_significant_digits() {
460
+ let filtered = sig(1., 5.).filter_default(&NumberFormatDefaults::builtin(true));
461
+ assert_eq!(filtered.minimum_significant_digits, Some(1.));
462
+ assert_eq!(filtered.maximum_significant_digits, Some(5.));
463
+
464
+ let filtered = sig(3., 21.).filter_default(&NumberFormatDefaults::builtin(true));
465
+ assert_eq!(filtered.minimum_significant_digits, Some(3.));
466
+ assert_eq!(filtered.maximum_significant_digits, Some(21.));
467
+ }
468
+
469
+ #[test]
470
+ fn filter_elides_values_equal_to_resolved_defaults() {
471
+ let defaults = NumberFormatDefaults {
472
+ fraction: (0., 1.),
473
+ notation: Notation::Compact(CompactDisplay::Short),
474
+ ..NumberFormatDefaults::builtin(true)
475
+ };
476
+
477
+ let config = CustomNumberFormatConfig {
478
+ _notation: Some(Notation::Compact(CompactDisplay::Short)),
479
+ minimum_fraction_digits: Some(0.),
480
+ maximum_fraction_digits: Some(1.),
481
+ ..Default::default()
482
+ };
483
+
484
+ assert_eq!(
485
+ config.filter_default(&defaults),
486
+ CustomNumberFormatConfig::default()
487
+ );
488
+ }
489
+
490
+ #[test]
491
+ fn filter_serializes_builtin_values_under_override() {
492
+ let defaults = NumberFormatDefaults {
493
+ notation: Notation::Compact(CompactDisplay::Short),
494
+ ..NumberFormatDefaults::builtin(true)
495
+ };
496
+
497
+ let config = CustomNumberFormatConfig {
498
+ _notation: Some(Notation::Standard),
499
+ ..Default::default()
500
+ };
501
+
502
+ let filtered = config.filter_default(&defaults);
503
+ assert_eq!(filtered._notation, Some(Notation::Standard));
504
+ }
505
+
506
+ #[test]
507
+ fn filter_bakes_fraction_digits_for_percent_style() {
508
+ let config = CustomNumberFormatConfig {
509
+ _style: Some(NumberFormatStyle::Percent),
510
+ ..Default::default()
511
+ };
512
+
513
+ let filtered = config.filter_default(&NumberFormatDefaults::builtin(true));
514
+ assert_eq!(filtered._style, Some(NumberFormatStyle::Percent));
515
+ assert_eq!(filtered.minimum_fraction_digits, Some(2.));
516
+ assert_eq!(filtered.maximum_fraction_digits, Some(2.));
517
+ }
325
518
  }
@@ -87,3 +87,13 @@ pub struct GetClientOptions {
87
87
  #[ts(optional)]
88
88
  pub panel: Option<String>,
89
89
  }
90
+
91
+ /// Options for the `saveWorkspace()` method.
92
+ #[derive(Deserialize, Default, TS)]
93
+ pub struct SaveWorkspaceOptions {
94
+ /// When `true`, the emitted `palette` is the full set the element
95
+ /// holds rather than only the values the panels reference.
96
+ #[serde(default)]
97
+ #[ts(optional)]
98
+ pub full_palette: Option<bool>,
99
+ }
@@ -24,7 +24,7 @@ use crate::renderer::ColumnConfigMap;
24
24
 
25
25
  /// The state of an entire `custom_elements::PerspectiveViewerElement` component
26
26
  /// and its `Plugin`: the element-level `settings` flag plus the per-panel
27
- /// [`PanelViewerConfig`]. The split exists so the whole-element config format
27
+ /// [`PanelViewerConfig`]. The split exists so the workspace config format
28
28
  /// can serialize panel entries *without* a `settings` key (it is element-level
29
29
  /// state there, carried by the top-level `active` field instead), while the
30
30
  /// single-panel format flattens back to the legacy shape.
@@ -39,7 +39,7 @@ pub struct ViewerConfig<V: TS = String> {
39
39
 
40
40
  /// The per-panel state of a [`ViewerConfig`] — everything except the
41
41
  /// element-level `settings` flag. This is the `panels` entry type of the
42
- /// whole-element config format.
42
+ /// workspace config format.
43
43
  #[derive(Debug, Default, Serialize, PartialEq, TS)]
44
44
  pub struct PanelViewerConfig<V: TS = String> {
45
45
  /// The `@perspective-dev/viewer` version that wrote this config,
@@ -315,15 +315,53 @@ impl From<ViewerConfigInitial> for ViewerConfigUpdate {
315
315
  }
316
316
  }
317
317
 
318
- // There is deliberately NO `TryFrom<ViewerConfigUpdate>` here. Requiring a
319
- // `table` is a property of the CREATION ENTRY POINTS — `addPanel`'s
320
- // argument type, `WorkspaceConfigUpdate::panels`, and the agent's
321
- // `add_panel` decode — each of which already has a `ViewerConfigInitial`
322
- // in hand. An update→initial conversion exists only to let a route holding
323
- // a PATCH pretend it is creating from scratch, which is how `restore`'s
324
- // upsert acquired the gate and started rejecting the table-less
325
- // restore-then-`load` contract. Its absence is what keeps that from
326
- // recurring: `create_panel` takes an update, so no caller needs one.
318
+ /// The rejection every panel-creating route without a `table` resolves to.
319
+ pub const CREATE_REQUIRES_TABLE: &str = "Cannot create a panel without a `table` — `load()` a \
320
+ `Client` and include `table` in the config, or use \
321
+ `addPanel()`";
322
+
323
+ fn down<T: Clone>(value: OptionalUpdate<T>) -> Option<T> {
324
+ match value {
325
+ OptionalUpdate::Update(value) => Some(value),
326
+ OptionalUpdate::Missing | OptionalUpdate::SetDefault => None,
327
+ }
328
+ }
329
+
330
+ impl TryFrom<ViewerConfigUpdate> for ViewerConfigInitial {
331
+ type Error = ApiError;
332
+
333
+ fn try_from(value: ViewerConfigUpdate) -> Result<Self, Self::Error> {
334
+ // Exhaustive (no `..`) on purpose — the same drift alarm as the
335
+ // `From<ViewerConfigInitial>` impl above. `settings` is element-level
336
+ // state, stripped by `restore()` before resolution; discarded here.
337
+ let ViewerConfigUpdate {
338
+ version,
339
+ plugin,
340
+ title,
341
+ table,
342
+ theme,
343
+ settings: _settings,
344
+ plugin_config,
345
+ columns_config,
346
+ view_config,
347
+ } = value;
348
+
349
+ let OptionalUpdate::Update(table) = table else {
350
+ return Err(ApiError::new(CREATE_REQUIRES_TABLE));
351
+ };
352
+
353
+ Ok(Self {
354
+ table,
355
+ version: down(version),
356
+ plugin: down(plugin),
357
+ title: down(title),
358
+ theme: down(theme),
359
+ plugin_config: down(plugin_config),
360
+ columns_config: down(columns_config),
361
+ view_config,
362
+ })
363
+ }
364
+ }
327
365
 
328
366
  impl std::fmt::Display for ViewerConfigUpdate {
329
367
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -360,50 +398,6 @@ pub type VersionUpdate = OptionalUpdate<String>;
360
398
  pub type ColumnConfigUpdate = OptionalUpdate<ColumnConfigMap>;
361
399
  pub type PluginConfigUpdate = OptionalUpdate<serde_json::Map<String, Value>>;
362
400
 
363
- #[cfg(test)]
364
- mod tests {
365
- use super::*;
366
-
367
- #[test]
368
- fn initial_requires_a_table() {
369
- let json = serde_json::json!({ "group_by": ["State"] });
370
- let err = serde_json::from_value::<ViewerConfigInitial>(json).unwrap_err();
371
- assert!(format!("{err}").contains("table"));
372
- }
373
-
374
- /// The creation → patch direction is the only one that exists (see the
375
- /// note above the `From` impl); a `table` becomes a concrete update and
376
- /// the element-level `settings` is not carried.
377
- #[test]
378
- fn initial_widens_to_an_update() {
379
- let json = serde_json::json!({
380
- "table": "superstore",
381
- "plugin": "Datagrid",
382
- "group_by": ["State"],
383
- });
384
-
385
- let initial: ViewerConfigInitial = serde_json::from_value(json).unwrap();
386
- let update = ViewerConfigUpdate::from(initial);
387
- assert!(matches!(&update.table, OptionalUpdate::Update(x) if x == "superstore"));
388
- assert!(matches!(&update.settings, OptionalUpdate::Missing));
389
- assert!(matches!(&update.plugin, OptionalUpdate::Update(x) if x == "Datagrid"));
390
- assert_eq!(
391
- update.view_config.group_by.as_deref(),
392
- Some(&["State".to_owned()][..])
393
- );
394
- }
395
-
396
- /// A table-less patch is a legitimate creation input now that
397
- /// `create_panel` takes an update — the deferred panel a `load()`
398
- /// binds. Nothing in this module may reject it.
399
- #[test]
400
- fn a_table_less_update_is_representable() {
401
- let json = serde_json::json!({ "group_by": ["State"] });
402
- let update: ViewerConfigUpdate = serde_json::from_value(json).unwrap();
403
- assert!(matches!(&update.table, OptionalUpdate::Missing));
404
- }
405
- }
406
-
407
401
  /// Handles `{}` when included as a field with `#[serde(default)]`.
408
402
  impl<T: Clone> Default for OptionalUpdate<T> {
409
403
  fn default() -> Self {
@@ -14,9 +14,9 @@ use std::collections::BTreeMap;
14
14
 
15
15
  use perspective_client::config::Filter;
16
16
 
17
- use crate::config::{PanelViewerConfig, ViewerConfigInitial};
17
+ use crate::config::{CssKind, PanelViewerConfig, ViewerConfigInitial};
18
18
 
19
- /// The whole-element config format (`{version, active?, layout, panels}`) —
19
+ /// The workspace config format (`{version, active?, layout, panels}`) —
20
20
  /// the multi-panel counterpart of the single-panel [`ViewerConfig`] — as
21
21
  /// emitted by [`PerspectiveViewerElement::save`].
22
22
  ///
@@ -56,9 +56,17 @@ pub struct WorkspaceConfig {
56
56
  #[ts(as = "Option<_>")]
57
57
  #[ts(optional)]
58
58
  pub masters: Vec<String>,
59
+
60
+ /// Named color-scale definitions shared by every panel: CSS custom
61
+ /// property name (`--psp-user--<kind>-<name>`) → canonical CSS
62
+ /// value.
63
+ #[serde(skip_serializing_if = "BTreeMap::is_empty")]
64
+ #[ts(as = "Option<_>")]
65
+ #[ts(optional)]
66
+ pub palette: BTreeMap<String, String>,
59
67
  }
60
68
 
61
- /// The parse target of a whole-element config in
69
+ /// The parse target of a workspace config in
62
70
  /// [`PerspectiveViewerElement::restoreWorkspace`]. Mirrors
63
71
  /// [`WorkspaceConfig`], but `panels` entries are [`ViewerConfigInitial`]s —
64
72
  /// every entry creates a NEW panel, so `table` is required by type (a
@@ -79,15 +87,134 @@ pub struct WorkspaceConfigUpdate {
79
87
  /// The element-level global (master/detail cross-) filters to re-apply as
80
88
  /// a transient overlay on every DETAIL panel. Restored as one
81
89
  /// unattributed bucket: the next selection on any master replaces it.
90
+ /// `Option` so an explicit `undefined` property deserializes as
91
+ /// `None` like an absent key (also `masters` / `palette` below).
82
92
  #[serde(default)]
83
93
  #[ts(as = "Option<_>")]
84
94
  #[ts(optional)]
85
- pub global_filters: Vec<Filter>,
95
+ pub global_filters: Option<Vec<Filter>>,
86
96
 
87
97
  /// The master (filter-source) panels, by saved `panels` key. An id not in
88
98
  /// `panels` warns and is dropped.
89
99
  #[serde(default)]
90
100
  #[ts(as = "Option<_>")]
91
101
  #[ts(optional)]
92
- pub masters: Vec<String>,
102
+ pub masters: Option<Vec<String>>,
103
+
104
+ /// Named color-scale definitions to apply to the host (see
105
+ /// [`WorkspaceConfig::palette`]), replacing any previously restored
106
+ /// palette.
107
+ #[serde(default)]
108
+ #[ts(as = "Option<_>")]
109
+ #[ts(optional)]
110
+ pub palette: Option<BTreeMap<String, String>>,
111
+ }
112
+
113
+ /// Validate a restored palette map: each key's `--psp-user--<kind>-`
114
+ /// prefix selects the reader that canonicalizes its value.
115
+ pub fn validate_palette(
116
+ palette: BTreeMap<String, String>,
117
+ ) -> Result<BTreeMap<String, String>, String> {
118
+ palette
119
+ .into_iter()
120
+ .map(|(name, value)| {
121
+ let kind = CssKind::of_var(&name).ok_or_else(|| {
122
+ format!(
123
+ "`palette` key `{name}` must start with `--psp-user--gradient-`, \
124
+ `--psp-user--palette-` or `--psp-user--color-`"
125
+ )
126
+ })?;
127
+
128
+ let canonical = kind
129
+ .canonicalize(&value)
130
+ .map_err(|error| format!("`palette[\"{name}\"]`: {error}"))?;
131
+
132
+ Ok((name, canonical))
133
+ })
134
+ .collect()
135
+ }
136
+
137
+ #[cfg(test)]
138
+ mod tests {
139
+ use serde_json::json;
140
+
141
+ use super::*;
142
+
143
+ #[test]
144
+ fn palette_serializes_only_when_present() {
145
+ let config = WorkspaceConfig {
146
+ version: "x".to_owned(),
147
+ active: None,
148
+ layout: None,
149
+ panels: BTreeMap::new(),
150
+ global_filters: vec![],
151
+ masters: vec![],
152
+ palette: BTreeMap::new(),
153
+ };
154
+
155
+ assert_eq!(
156
+ serde_json::to_value(&config).unwrap(),
157
+ json!({ "version": "x", "layout": null, "panels": {} })
158
+ );
159
+
160
+ let mut palette = BTreeMap::new();
161
+ palette.insert("--psp-user--color-hot".to_owned(), "#ff0000".to_owned());
162
+
163
+ let config = WorkspaceConfig { palette, ..config };
164
+ assert_eq!(
165
+ serde_json::to_value(&config).unwrap(),
166
+ json!({
167
+ "version": "x",
168
+ "layout": null,
169
+ "panels": {},
170
+ "palette": { "--psp-user--color-hot": "#ff0000" },
171
+ })
172
+ );
173
+ }
174
+
175
+ #[test]
176
+ fn update_palette_defaults_empty_and_validates_by_prefix() {
177
+ let update: WorkspaceConfigUpdate =
178
+ serde_json::from_value(json!({ "panels": {} })).unwrap();
179
+ assert!(update.palette.is_none());
180
+
181
+ let update: WorkspaceConfigUpdate = serde_json::from_value(json!({
182
+ "panels": {},
183
+ "palette": {
184
+ "--psp-user--gradient-1": "linear-gradient(#000, #fff)",
185
+ "--psp-user--palette-warm": "linear-gradient(90deg, RGB(255,0,0), #ff0)",
186
+ "--psp-user--color-hot": "#F00",
187
+ },
188
+ }))
189
+ .unwrap();
190
+
191
+ let valid = validate_palette(update.palette.unwrap()).unwrap();
192
+ assert_eq!(
193
+ valid.get("--psp-user--gradient-1").unwrap(),
194
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)"
195
+ );
196
+ assert_eq!(
197
+ valid.get("--psp-user--palette-warm").unwrap(),
198
+ "linear-gradient(to right, #ff0000, #ffff00)"
199
+ );
200
+ assert_eq!(valid.get("--psp-user--color-hot").unwrap(), "#ff0000");
201
+
202
+ let bad = |name: &str, value: &str| {
203
+ let mut map = BTreeMap::new();
204
+ map.insert(name.to_owned(), value.to_owned());
205
+ validate_palette(map).unwrap_err()
206
+ };
207
+
208
+ assert!(bad("--psp-user--other-1", "#ff0000").contains("--psp-user--other-1"));
209
+ assert!(bad("--psp-charts--gradient", "#ff0000").contains("must start with"));
210
+ assert!(
211
+ bad(
212
+ "--psp-user--palette-1",
213
+ "linear-gradient(#000 0%, #fff 100%)"
214
+ )
215
+ .contains("--psp-user--palette-1")
216
+ );
217
+ assert!(bad("--psp-user--gradient-1", "#ff0000").contains("linear-gradient"));
218
+ assert!(bad("--psp-user--color-1", "red").contains("red"));
219
+ }
93
220
  }