@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
@@ -166,7 +166,7 @@ impl Component for PerspectiveViewer {
166
166
  let renderer_props = active_renderer.to_props(None);
167
167
  let presentation_props = ctx.props().presentation.to_props(PtrEqRc::new(vec![]));
168
168
  let on_close_column_settings = ctx.link().callback(|_| OpenColumnSettings {
169
- locator: None,
169
+ target: None,
170
170
  sender: None,
171
171
  toggle: false,
172
172
  });
@@ -319,8 +319,8 @@ impl Component for PerspectiveViewer {
319
319
  ClearGlobalFilters => self.on_clear_global_filters(ctx),
320
320
 
321
321
  // Settings sidebar + divider pump + column settings (`settings.rs`)
322
- ToggleSettingsInit(update, resolve) => {
323
- self.on_toggle_settings_init(ctx, update, resolve)
322
+ ToggleSettingsInit(update, announce, resolve) => {
323
+ self.on_toggle_settings_init(ctx, update, announce, resolve)
324
324
  },
325
325
  ToggleSettingsComplete(update, resolve) => {
326
326
  self.on_toggle_settings_complete(ctx, update, resolve)
@@ -333,25 +333,29 @@ impl Component for PerspectiveViewer {
333
333
  SettingsPanelTabChanged(tab) => self.on_settings_panel_tab_changed(tab),
334
334
  SettingsPanelAutoWidth(w) => self.on_settings_panel_auto_width(w),
335
335
  OpenColumnSettings {
336
- locator,
336
+ target,
337
337
  sender,
338
338
  toggle,
339
- } => self.on_open_column_settings(ctx, locator, sender, toggle),
339
+ } => self.on_open_column_settings(ctx, target, sender, toggle),
340
340
  ColumnSettingsPanelSizeUpdate(x) => self.on_column_settings_panel_size_update(x),
341
341
  ColumnSettingsPanelAutoWidth(w) => self.on_column_settings_panel_auto_width(w),
342
- ToggleColumnSettingsPin => self.on_toggle_column_settings_pin(),
342
+ ToggleColumnSettingsPin => self.on_toggle_column_settings_pin(ctx),
343
+ ToggleColumnSettingsPinComplete(resolve) => {
344
+ self.on_toggle_column_settings_pin_complete(resolve)
345
+ },
343
346
  ColumnSettingsTabChanged(tab) => self.on_column_settings_tab_changed(ctx, tab),
344
347
  ToggleDebug => self.on_toggle_debug(ctx),
345
348
 
346
349
  // Value-semantic snapshot plumbing (`snapshots.rs`)
347
- UpdateSession(props) => self.on_update_session(*props),
350
+ UpdateSession(props) => self.on_update_session(ctx, *props),
348
351
  UpdateSessionStats(stats, has_table) => self.on_update_session_stats(stats, has_table),
349
352
  UpdateGlobalFilters => self.on_update_global_filters(ctx),
350
353
  UpdateRenderer(props) => self.on_update_renderer(*props),
351
354
  UpdatePresentation(props) => self.on_update_presentation(ctx, *props),
352
355
  UpdateSettingsOpen(open) => self.on_update_settings_open(open),
353
356
  UpdateIsWorkspace(is_workspace) => self.on_update_is_workspace(is_workspace),
354
- UpdateColumnSettings(ocs) => self.on_update_column_settings(*ocs),
357
+ UpdateColumnSettings(ocs) => self.on_update_column_settings(ctx, *ocs),
358
+ UpdateColumnSettingsCommit(resolve) => self.on_update_column_settings_commit(resolve),
355
359
  UpdateDragDrop(props) => self.on_update_dragdrop(*props),
356
360
  UpdateInFlight(count) => self.on_update_in_flight(count),
357
361
  }
@@ -15,18 +15,15 @@ use std::collections::HashSet;
15
15
  use serde::{Deserialize, Serialize};
16
16
  use serde_json::Value;
17
17
 
18
- use super::{KeyValueOpts, NumberSeriesStyleDefaultConfig};
18
+ use super::{
19
+ CssKind, CustomNumberFormatConfig, DatetimeFormatType, KeyValueOpts,
20
+ NumberSeriesStyleDefaultConfig,
21
+ };
19
22
 
20
23
  /// The full schema for one column at one point in time. Plugins may return
21
24
  /// different schemas for the same column based on the column's current
22
25
  /// stored value (e.g. to hide dependent fields), so this is re-queried on
23
26
  /// every field update.
24
- ///
25
- /// Each entry is a [`ControlSpec`]. Primitive variants carry their own
26
- /// `key` (JSON storage key) inline; the sidebar UI label is supplied by
27
- /// CSS via `--psp-label--<key>--content`. Composite variants render a
28
- /// self-contained Yew component that supplies its own labels and owns a
29
- /// fixed key namespace via [`ControlSpec::serialized_keys`].
30
27
  #[derive(Clone, Debug, Default, Deserialize, Serialize)]
31
28
  pub struct ColumnConfigSchema {
32
29
  pub fields: Vec<ControlSpec>,
@@ -47,6 +44,21 @@ impl ColumnConfigSchema {
47
44
  }
48
45
  out
49
46
  }
47
+
48
+ pub fn leaf_fields(&self) -> Vec<&ControlSpec> {
49
+ fn collect<'a>(fields: &'a [ControlSpec], out: &mut Vec<&'a ControlSpec>) {
50
+ for spec in fields {
51
+ match spec {
52
+ ControlSpec::Group { fields, .. } => collect(fields, out),
53
+ leaf => out.push(leaf),
54
+ }
55
+ }
56
+ }
57
+
58
+ let mut out = vec![];
59
+ collect(&self.fields, &mut out);
60
+ out
61
+ }
50
62
  }
51
63
 
52
64
  /// Discriminated union of widget kinds the viewer can render. Composite
@@ -93,33 +105,26 @@ pub enum ControlSpec {
93
105
  key: String,
94
106
  default: String,
95
107
  },
96
- /// Paired pos/neg color picker rendered as a single horizontal
97
- /// gradient/range bar. Used to expose the existing
98
- /// [`crate::components::form::color_range_selector::ColorRangeSelector`]
99
- /// widget at primitive granularity. Owns two top-level keys
100
- /// (`key_pos` + `key_neg`); the visible label is derived from
101
- /// `key_pos`.
102
- ColorRange {
103
- key_pos: String,
104
- key_neg: String,
105
- default_pos: String,
106
- default_neg: String,
107
- /// When `true`, the bar renders as a continuous gradient
108
- /// (e.g. for `gradient` color modes); when `false`, the bar
109
- /// renders as a hard pos/neg split. Currently only changes the
110
- /// visual; pos/neg semantics are unchanged.
111
- #[serde(default)]
112
- is_gradient: bool,
108
+ Palette {
109
+ key: String,
110
+ default: String,
111
+
112
+ #[serde(default, skip_serializing_if = "Option::is_none")]
113
+ max: Option<usize>,
113
114
  },
115
+ GradientStops {
116
+ key: String,
117
+ default: String,
114
118
 
115
- /// Residual format-only widget for datetime columns — owns
116
- /// `date_format` only. Pair with primitive `Enum` and `Color` fields
117
- /// for `datetime_color_mode` + `color` to fully decompose datetime
118
- /// styling.
119
- DatetimeFormat,
120
- /// Residual format-only widget for string columns — owns `format`
121
- /// only. Pair with primitive `Enum` and `Color` fields for
122
- /// `string_color_mode` + `color` to fully decompose string styling.
119
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
120
+ discrete: bool,
121
+ },
122
+ DatetimeFormat {
123
+ /// Plugin-declared default `date_format`, shown by the editor in
124
+ /// unedited fields and elided from serialized configs.
125
+ #[serde(default, skip_serializing_if = "Option::is_none")]
126
+ default: Option<DatetimeFormatType>,
127
+ },
123
128
  StringFormat,
124
129
  NumberSeriesStyle {
125
130
  default: NumberSeriesStyleDefaultConfig,
@@ -127,8 +132,19 @@ pub enum ControlSpec {
127
132
  Symbols {
128
133
  default: KeyValueOpts,
129
134
  },
130
- NumberFormat,
135
+ NumberFormat {
136
+ /// Plugin-declared default format, keyed like `number_format`
137
+ /// itself.
138
+ #[serde(default, skip_serializing_if = "Option::is_none")]
139
+ default: Option<CustomNumberFormatConfig>,
140
+ },
131
141
  AggregateDepth,
142
+
143
+ Group {
144
+ key: String,
145
+ #[serde(default)]
146
+ fields: Vec<ControlSpec>,
147
+ },
132
148
  }
133
149
 
134
150
  #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@@ -138,6 +154,143 @@ pub struct EnumVariant {
138
154
  pub label: Option<String>,
139
155
  }
140
156
 
157
+ /// One stop of a [`ControlSpec::GradientStops`] value in its in-memory
158
+ /// form: a `#rrggbb` color at `offset` ∈ `[0, 1]`.
159
+ #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
160
+ pub struct GradientStopSpec {
161
+ pub color: String,
162
+ pub offset: f64,
163
+ }
164
+
165
+ /// The [`ControlSpec::GradientStops`] canonical stop order: offsets
166
+ /// clamped to `[0, 1]` and rounded to 3 decimals, stops sorted stably
167
+ /// by offset.
168
+ pub fn canonicalize_gradient_stops(mut stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
169
+ for stop in &mut stops {
170
+ stop.offset = (stop.offset.clamp(0.0, 1.0) * 1000.0).round() / 1000.0;
171
+ }
172
+
173
+ stops.sort_by(|a, b| {
174
+ a.offset
175
+ .partial_cmp(&b.offset)
176
+ .unwrap_or(std::cmp::Ordering::Equal)
177
+ });
178
+
179
+ stops
180
+ }
181
+
182
+ /// Fit `stops` to a `discrete` field's fixed pair: an over-length value
183
+ /// keeps only its two end colors, pinned to `0`/`1`.
184
+ pub fn discrete_pair(stops: Vec<GradientStopSpec>) -> Vec<GradientStopSpec> {
185
+ let stops = canonicalize_gradient_stops(stops);
186
+ match (stops.first(), stops.last()) {
187
+ (Some(first), Some(last)) if stops.len() > 2 => vec![
188
+ GradientStopSpec {
189
+ color: first.color.clone(),
190
+ offset: 0.0,
191
+ },
192
+ GradientStopSpec {
193
+ color: last.color.clone(),
194
+ offset: 1.0,
195
+ },
196
+ ],
197
+ _ => stops,
198
+ }
199
+ }
200
+
201
+ impl ColumnConfigSchema {
202
+ pub fn canonicalize(self) -> Self {
203
+ self.canonicalize_defaults().group_format_controls()
204
+ }
205
+
206
+ pub fn group_format_controls(mut self) -> Self {
207
+ fn is_format(spec: &ControlSpec) -> bool {
208
+ matches!(
209
+ spec,
210
+ ControlSpec::NumberFormat { .. }
211
+ | ControlSpec::DatetimeFormat { .. }
212
+ | ControlSpec::StringFormat
213
+ )
214
+ }
215
+
216
+ fn walk(fields: &mut Vec<ControlSpec>) {
217
+ for spec in fields.iter_mut() {
218
+ if let ControlSpec::Group { key, fields } = spec
219
+ && key != "format"
220
+ {
221
+ walk(fields);
222
+ }
223
+ }
224
+
225
+ let first = fields.iter().position(is_format);
226
+ if let Some(first) = first {
227
+ let mut formats = vec![];
228
+ let mut i = first;
229
+ while i < fields.len() {
230
+ if is_format(&fields[i]) {
231
+ formats.push(fields.remove(i));
232
+ } else {
233
+ i += 1;
234
+ }
235
+ }
236
+
237
+ fields.insert(first, ControlSpec::Group {
238
+ key: "format".to_owned(),
239
+ fields: formats,
240
+ });
241
+ }
242
+ }
243
+
244
+ walk(&mut self.fields);
245
+ self
246
+ }
247
+
248
+ /// Canonicalize every CSS-valued default at schema ingest, dropping
249
+ /// (and logging) fields whose default fails its kind's reader.
250
+ pub fn canonicalize_defaults(mut self) -> Self {
251
+ fn canonicalize_specs(fields: &mut Vec<ControlSpec>) {
252
+ fields.retain_mut(|spec| {
253
+ let (kind, key, default) = match spec {
254
+ ControlSpec::Group { fields, .. } => {
255
+ canonicalize_specs(fields);
256
+ return !fields.is_empty();
257
+ },
258
+ ControlSpec::Color { key, default } => (CssKind::Color, key, default),
259
+ ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
260
+ ControlSpec::GradientStops { key, default, .. } => {
261
+ (CssKind::Gradient, key, default)
262
+ },
263
+ _ => return true,
264
+ };
265
+
266
+ match kind.canonicalize(default) {
267
+ Ok(canonical) => {
268
+ *default = canonical;
269
+ true
270
+ },
271
+ Err(error) => {
272
+ tracing::error!("Dropping `{key}` — invalid schema default: {error}");
273
+ false
274
+ },
275
+ }
276
+ });
277
+ }
278
+
279
+ canonicalize_specs(&mut self.fields);
280
+ self
281
+ }
282
+
283
+ /// The CSS kind of the control owning `key`, if it is CSS-valued.
284
+ pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
285
+ self.leaf_fields().into_iter().find_map(|spec| match spec {
286
+ ControlSpec::Color { key: k, .. } if k == key => Some(CssKind::Color),
287
+ ControlSpec::Palette { key: k, .. } if k == key => Some(CssKind::Palette),
288
+ ControlSpec::GradientStops { key: k, .. } if k == key => Some(CssKind::Gradient),
289
+ _ => None,
290
+ })
291
+ }
292
+ }
293
+
141
294
  impl ControlSpec {
142
295
  /// Top-level JSON keys this control owns when its value is serialized
143
296
  /// into a column's config map. For primitives this is just `[key]`;
@@ -146,20 +299,22 @@ impl ControlSpec {
146
299
  /// `columns_config` blob passed to `plugin.restore()`.
147
300
  pub fn serialized_keys(&self) -> Vec<&str> {
148
301
  match self {
149
- ControlSpec::DatetimeFormat => vec!["date_format"],
302
+ ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
150
303
  ControlSpec::StringFormat => vec!["format"],
151
304
  ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
152
305
  ControlSpec::Symbols { .. } => vec!["symbols"],
153
- ControlSpec::NumberFormat => vec!["number_format"],
306
+ ControlSpec::NumberFormat { .. } => vec!["number_format"],
154
307
  ControlSpec::AggregateDepth => vec!["aggregate_depth"],
155
- ControlSpec::ColorRange {
156
- key_pos, key_neg, ..
157
- } => vec![key_pos.as_str(), key_neg.as_str()],
158
308
  ControlSpec::Enum { key, .. }
159
309
  | ControlSpec::Bool { key, .. }
160
310
  | ControlSpec::Number { key, .. }
161
311
  | ControlSpec::String { key, .. }
162
- | ControlSpec::Color { key, .. } => vec![key.as_str()],
312
+ | ControlSpec::Color { key, .. }
313
+ | ControlSpec::Palette { key, .. }
314
+ | ControlSpec::GradientStops { key, .. } => vec![key.as_str()],
315
+ ControlSpec::Group { fields, .. } => {
316
+ fields.iter().flat_map(|f| f.serialized_keys()).collect()
317
+ },
163
318
  }
164
319
  }
165
320
  }
@@ -168,12 +323,6 @@ impl ControlSpec {
168
323
  /// declares which top-level keys the update is allowed to write
169
324
  /// (`keys` — equivalent to the field's [`ControlSpec::serialized_keys`])
170
325
  /// and a partial new sub-state (`value`).
171
- ///
172
- /// Apply semantics: keys in `keys` are *cleared* from the column's config
173
- /// map, then keys present in `value` are *inserted*. Defaults are
174
- /// pre-stripped by the caller (typically via `skip_serializing_if`), so
175
- /// "no value set for key K" means the schema default applies and K is
176
- /// not serialized.
177
326
  #[derive(Clone, Debug, Deserialize, Serialize)]
178
327
  pub struct ColumnConfigFieldUpdate {
179
328
  pub keys: Vec<String>,
@@ -193,3 +342,244 @@ pub fn filter_to_schema(
193
342
  .map(|(k, v)| (k.clone(), v.clone()))
194
343
  .collect()
195
344
  }
345
+
346
+ #[cfg(test)]
347
+ mod tests {
348
+ use serde_json::json;
349
+
350
+ use super::*;
351
+
352
+ fn color(key: &str, default: &str) -> ControlSpec {
353
+ ControlSpec::Color {
354
+ key: key.to_owned(),
355
+ default: default.to_owned(),
356
+ }
357
+ }
358
+
359
+ fn flag(key: &str) -> ControlSpec {
360
+ ControlSpec::Bool {
361
+ key: key.to_owned(),
362
+ default: false,
363
+ }
364
+ }
365
+
366
+ fn group(key: &str, fields: Vec<ControlSpec>) -> ControlSpec {
367
+ ControlSpec::Group {
368
+ key: key.to_owned(),
369
+ fields,
370
+ }
371
+ }
372
+
373
+ #[test]
374
+ fn group_deserializes_recursively() {
375
+ let schema: ColumnConfigSchema = serde_json::from_value(json!({
376
+ "fields": [{
377
+ "kind": "Group",
378
+ "key": "legend",
379
+ "fields": [
380
+ { "kind": "Bool", "key": "legend_on", "default": false },
381
+ {
382
+ "kind": "Group",
383
+ "key": "inner",
384
+ "fields": [{ "kind": "Color", "key": "color", "default": "#ff0000" }]
385
+ }
386
+ ]
387
+ }]
388
+ }))
389
+ .unwrap();
390
+
391
+ let keys = schema.active_keys();
392
+ assert_eq!(
393
+ keys,
394
+ HashSet::from(["legend_on".to_owned(), "color".to_owned()])
395
+ );
396
+
397
+ let leaves = schema.leaf_fields();
398
+ assert_eq!(leaves.len(), 2);
399
+ assert!(
400
+ leaves
401
+ .iter()
402
+ .all(|s| !matches!(s, ControlSpec::Group { .. }))
403
+ );
404
+ }
405
+
406
+ #[test]
407
+ fn grouped_schema_is_equivalent_to_flat() {
408
+ let flat = ColumnConfigSchema {
409
+ fields: vec![flag("stack"), color("color", "#0366d6")],
410
+ };
411
+
412
+ let grouped = ColumnConfigSchema {
413
+ fields: vec![group("series", vec![
414
+ flag("stack"),
415
+ color("color", "#0366d6"),
416
+ ])],
417
+ };
418
+
419
+ assert_eq!(flat.active_keys(), grouped.active_keys());
420
+ assert_eq!(flat.css_kind_of("color"), grouped.css_kind_of("color"));
421
+ assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
422
+ }
423
+
424
+ #[test]
425
+ fn format_controls_group_and_merge() {
426
+ let schema = ColumnConfigSchema {
427
+ fields: vec![
428
+ ControlSpec::NumberFormat { default: None },
429
+ flag("flag"),
430
+ ControlSpec::StringFormat,
431
+ ],
432
+ }
433
+ .group_format_controls();
434
+
435
+ assert_eq!(schema.fields.len(), 2);
436
+ let ControlSpec::Group { key, fields } = &schema.fields[0] else {
437
+ panic!("expected format group first");
438
+ };
439
+
440
+ assert_eq!(key, "format");
441
+ assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
442
+ assert!(matches!(fields[1], ControlSpec::StringFormat));
443
+ assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
444
+
445
+ assert_eq!(
446
+ schema.active_keys(),
447
+ HashSet::from([
448
+ "number_format".to_owned(),
449
+ "format".to_owned(),
450
+ "flag".to_owned()
451
+ ])
452
+ );
453
+ }
454
+
455
+ #[test]
456
+ fn format_grouping_recurses_but_never_double_wraps() {
457
+ let schema = ColumnConfigSchema {
458
+ fields: vec![
459
+ group("format", vec![ControlSpec::NumberFormat { default: None }]),
460
+ group("styling", vec![flag("x"), ControlSpec::DatetimeFormat {
461
+ default: None,
462
+ }]),
463
+ ],
464
+ }
465
+ .group_format_controls();
466
+
467
+ let ControlSpec::Group { key, fields } = &schema.fields[0] else {
468
+ panic!("expected group");
469
+ };
470
+
471
+ assert_eq!(key, "format");
472
+ assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
473
+
474
+ let ControlSpec::Group { fields, .. } = &schema.fields[1] else {
475
+ panic!("expected group");
476
+ };
477
+
478
+ assert!(matches!(
479
+ &fields[1],
480
+ ControlSpec::Group { key, fields }
481
+ if key == "format" && matches!(fields[0], ControlSpec::DatetimeFormat { .. })
482
+ ));
483
+ }
484
+
485
+ #[test]
486
+ fn format_controls_deserialize_without_default_payload() {
487
+ let schema: ColumnConfigSchema = serde_json::from_value(json!({
488
+ "fields": [{ "kind": "NumberFormat" }, { "kind": "DatetimeFormat" }]
489
+ }))
490
+ .unwrap();
491
+
492
+ assert!(matches!(&schema.fields[0], ControlSpec::NumberFormat {
493
+ default: None
494
+ }));
495
+ assert!(matches!(&schema.fields[1], ControlSpec::DatetimeFormat {
496
+ default: None
497
+ }));
498
+ }
499
+
500
+ #[test]
501
+ fn number_format_default_payload_deserializes_flattened_families() {
502
+ let schema: ColumnConfigSchema = serde_json::from_value(json!({
503
+ "fields": [{
504
+ "kind": "NumberFormat",
505
+ "default": {
506
+ "notation": "compact",
507
+ "compactDisplay": "short",
508
+ "minimumFractionDigits": 0,
509
+ "maximumFractionDigits": 1
510
+ }
511
+ }]
512
+ }))
513
+ .unwrap();
514
+
515
+ let ControlSpec::NumberFormat {
516
+ default: Some(default),
517
+ } = &schema.fields[0]
518
+ else {
519
+ panic!("expected NumberFormat with default");
520
+ };
521
+
522
+ assert_eq!(
523
+ default._notation,
524
+ Some(crate::config::Notation::Compact(
525
+ crate::config::CompactDisplay::Short
526
+ ))
527
+ );
528
+ assert_eq!(default._style, None);
529
+ assert_eq!(default.minimum_fraction_digits, Some(0.));
530
+ assert_eq!(default.maximum_fraction_digits, Some(1.));
531
+ assert_eq!(
532
+ schema.active_keys(),
533
+ HashSet::from(["number_format".to_owned()])
534
+ );
535
+ }
536
+
537
+ #[test]
538
+ fn datetime_format_default_payload_deserializes_simple_arm() {
539
+ let schema: ColumnConfigSchema = serde_json::from_value(json!({
540
+ "fields": [{
541
+ "kind": "DatetimeFormat",
542
+ "default": { "dateStyle": "medium", "timeStyle": "disabled" }
543
+ }]
544
+ }))
545
+ .unwrap();
546
+
547
+ let ControlSpec::DatetimeFormat {
548
+ default: Some(DatetimeFormatType::Simple(simple)),
549
+ } = &schema.fields[0]
550
+ else {
551
+ panic!("expected DatetimeFormat with Simple default");
552
+ };
553
+
554
+ assert_eq!(
555
+ simple.date_style,
556
+ crate::config::SimpleDatetimeFormat::Medium
557
+ );
558
+ assert_eq!(
559
+ simple.time_style,
560
+ crate::config::SimpleDatetimeFormat::Disabled
561
+ );
562
+ }
563
+
564
+ #[test]
565
+ fn canonicalize_defaults_recurses_and_drops_empty_groups() {
566
+ let schema = ColumnConfigSchema {
567
+ fields: vec![
568
+ group("ok", vec![color("good", "RGB(255,0,0)"), flag("flag")]),
569
+ group("doomed", vec![color("bad", "not-a-color")]),
570
+ ],
571
+ }
572
+ .canonicalize_defaults();
573
+
574
+ assert_eq!(schema.fields.len(), 1);
575
+ let ControlSpec::Group { key, fields } = &schema.fields[0] else {
576
+ panic!("expected group");
577
+ };
578
+
579
+ assert_eq!(key, "ok");
580
+ assert!(matches!(
581
+ &fields[0],
582
+ ControlSpec::Color { default, .. } if default == "#ff0000"
583
+ ));
584
+ }
585
+ }