@perspective-dev/viewer 5.4.0 → 5.5.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 (129) hide show
  1. package/dist/cdn/perspective-viewer.js +4 -4
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/blueprint.css +1 -1
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -1
  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/ar.css +1 -1
  11. package/dist/css/intl/de.css +1 -1
  12. package/dist/css/intl/es.css +1 -1
  13. package/dist/css/intl/fr.css +1 -1
  14. package/dist/css/intl/ja.css +1 -1
  15. package/dist/css/intl/pt.css +1 -1
  16. package/dist/css/intl/zh.css +1 -1
  17. package/dist/css/intl.css +1 -1
  18. package/dist/css/ledger.css +1 -1
  19. package/dist/css/monokai.css +1 -1
  20. package/dist/css/nord.css +1 -1
  21. package/dist/css/phosphor.css +1 -1
  22. package/dist/css/pro-dark.css +1 -1
  23. package/dist/css/pro.css +1 -1
  24. package/dist/css/solarized-dark.css +1 -1
  25. package/dist/css/solarized.css +1 -1
  26. package/dist/css/themes.css +1 -1
  27. package/dist/css/vaporwave.css +1 -1
  28. package/dist/css/velvet.css +1 -1
  29. package/dist/docs/perspective-docs.json +1 -1
  30. package/dist/esm/perspective-viewer.d.ts +2 -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 +4 -4
  34. package/dist/esm/perspective-viewer.js.map +2 -2
  35. package/dist/esm/plugin.d.ts +9 -0
  36. package/dist/esm/ts-rs/AddPanelOptions.d.ts +9 -0
  37. package/dist/esm/ts-rs/GetViewMode.d.ts +4 -0
  38. package/dist/esm/ts-rs/GetViewOptions.d.ts +8 -0
  39. package/dist/esm/ts-rs/RestoreOptions.d.ts +6 -0
  40. package/dist/esm/ts-rs/RestoreWorkspaceOptions.d.ts +9 -0
  41. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +30 -22
  42. package/dist/wasm/perspective-viewer.d.ts +91 -47
  43. package/dist/wasm/perspective-viewer.js +216 -136
  44. package/dist/wasm/perspective-viewer.wasm +0 -0
  45. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -18
  46. package/package.json +5 -5
  47. package/src/css/column-selector.css +11 -3
  48. package/src/css/column-style.css +162 -0
  49. package/src/css/config-selector.css +1 -1
  50. package/src/css/form/debug.css +9 -0
  51. package/src/css/status-bar.css +11 -0
  52. package/src/css/ui/containers/tabs.css +2 -1
  53. package/src/rust/agent/tools.rs +80 -7
  54. package/src/rust/components/column_selector/active_column.rs +9 -0
  55. package/src/rust/components/column_selector.rs +11 -0
  56. package/src/rust/components/column_settings_sidebar/style_tab/alignment_field.rs +90 -0
  57. package/src/rust/components/column_settings_sidebar/style_tab/font_field.rs +267 -0
  58. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +1 -1
  59. package/src/rust/components/column_settings_sidebar/style_tab.rs +68 -9
  60. package/src/rust/components/debug_panel.rs +107 -50
  61. package/src/rust/components/main_panel/msg.rs +4 -0
  62. package/src/rust/components/main_panel.rs +14 -0
  63. package/src/rust/components/mod.rs +0 -1
  64. package/src/rust/components/plugin_tab.rs +57 -3
  65. package/src/rust/components/settings_panel.rs +6 -0
  66. package/src/rust/components/status_indicator.rs +4 -0
  67. package/src/rust/components/viewer/panels.rs +3 -0
  68. package/src/rust/components/viewer/render.rs +1 -0
  69. package/src/rust/components/viewer/wiring.rs +10 -1
  70. package/src/rust/config/column_config_schema.rs +312 -8
  71. package/src/rust/config/datetime_column_style.rs +0 -11
  72. package/src/rust/config/mod.rs +0 -2
  73. package/src/rust/config/options.rs +50 -0
  74. package/src/rust/config/workspace_config.rs +69 -24
  75. package/src/rust/custom_elements/viewer.rs +194 -97
  76. package/src/rust/js/plugin.rs +3 -1
  77. package/src/rust/lib.rs +7 -3
  78. package/src/rust/queries/plugin_column_styles.rs +6 -5
  79. package/src/rust/renderer/plugin_config.rs +200 -41
  80. package/src/rust/renderer/plugin_store.rs +2 -1
  81. package/src/rust/renderer/props.rs +5 -0
  82. package/src/rust/renderer.rs +9 -0
  83. package/src/rust/session/props.rs +4 -0
  84. package/src/rust/session.rs +151 -80
  85. package/src/rust/tasks/create_panel.rs +4 -1
  86. package/src/rust/tasks/pipeline.rs +18 -7
  87. package/src/rust/tasks/restore_panel.rs +129 -30
  88. package/src/rust/tasks/send_column_config.rs +3 -0
  89. package/src/rust/tasks/sync_update_panels.rs +147 -67
  90. package/src/rust/tasks/table_lifecycle.rs +7 -1
  91. package/src/rust/ui/containers/tab_list.rs +1 -1
  92. package/src/rust/ui/form/intl_label.rs +4 -23
  93. package/src/rust/ui/portal.rs +1 -0
  94. package/src/rust/utils/css_vars.rs +13 -0
  95. package/src/rust/utils/local_fonts.rs +395 -0
  96. package/src/rust/utils/mod.rs +2 -0
  97. package/src/rust/workspace.rs +12 -0
  98. package/src/svg/status_pending.svg +3 -0
  99. package/src/themes/icons.css +1 -0
  100. package/src/themes/intl/ar.css +20 -7
  101. package/src/themes/intl/de.css +20 -7
  102. package/src/themes/intl/es.css +20 -7
  103. package/src/themes/intl/fr.css +20 -7
  104. package/src/themes/intl/ja.css +20 -7
  105. package/src/themes/intl/pt.css +20 -7
  106. package/src/themes/intl/zh.css +20 -7
  107. package/src/themes/intl.css +20 -7
  108. package/src/ts/perspective-viewer.ts +2 -0
  109. package/src/ts/plugin.ts +9 -0
  110. package/src/ts/ts-rs/AddPanelOptions.ts +10 -0
  111. package/src/ts/ts-rs/{DatetimeColorMode.ts → GetViewMode.ts} +4 -1
  112. package/src/ts/ts-rs/GetViewOptions.ts +7 -0
  113. package/src/ts/ts-rs/RestoreOptions.ts +7 -1
  114. package/src/ts/ts-rs/RestoreWorkspaceOptions.ts +10 -0
  115. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +31 -20
  116. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +0 -1
  117. package/dist/esm/ts-rs/FormatMode.d.ts +0 -1
  118. package/dist/esm/ts-rs/StringColorMode.d.ts +0 -1
  119. package/src/rust/components/string_column_style.rs +0 -120
  120. package/src/rust/config/datetime_column_style/color_mode.rs +0 -62
  121. package/src/rust/config/string_column_style.rs +0 -135
  122. package/src/ts/ts-rs/FormatMode.ts +0 -3
  123. package/src/ts/ts-rs/StringColorMode.ts +0 -3
  124. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline0.js +0 -0
  125. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline1.js +0 -0
  126. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline2.js +0 -0
  127. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline3.js +0 -0
  128. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline4.js +0 -0
  129. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-ea71a3990cad35d7}/inline5.js +0 -0
@@ -250,6 +250,9 @@ pub struct MainPanel {
250
250
  /// `Workspace::staged_changed` → [`MainPanelMsg::StagedChanged`] on THIS
251
251
  /// component's scope (see the message doc for why not the root's).
252
252
  _staged_sub: crate::utils::Subscription,
253
+
254
+ /// `Workspace::layout_staged` → [`MainPanelMsg::LayoutStaged`].
255
+ _layout_staged_sub: crate::utils::Subscription,
253
256
  }
254
257
 
255
258
  impl Component for MainPanel {
@@ -351,6 +354,15 @@ impl Component for MainPanel {
351
354
  .add_listener(move |()| cb.emit(()))
352
355
  };
353
356
 
357
+ let layout_staged_sub = {
358
+ use crate::utils::AddListener;
359
+ let cb = ctx.link().callback(|_: ()| MainPanelMsg::LayoutStaged);
360
+ ctx.props()
361
+ .workspace
362
+ .layout_staged()
363
+ .add_listener(move |()| cb.emit(()))
364
+ };
365
+
354
366
  Self {
355
367
  main_panel_ref: NodeRef::default(),
356
368
  layout_ref: NodeRef::default(),
@@ -368,6 +380,7 @@ impl Component for MainPanel {
368
380
  theme_backgrounds: HashMap::new(),
369
381
  stamped_frame_themes: None,
370
382
  _staged_sub: staged_sub,
383
+ _layout_staged_sub: layout_staged_sub,
371
384
  }
372
385
  }
373
386
 
@@ -375,6 +388,7 @@ impl Component for MainPanel {
375
388
  match msg {
376
389
  MainPanelMsg::PointerEvent(event) => self.on_pointer_event(ctx, event),
377
390
  MainPanelMsg::StagedChanged => true,
391
+ MainPanelMsg::LayoutStaged => true,
378
392
  MainPanelMsg::LayoutUpdated => self.on_layout_updated(ctx),
379
393
  MainPanelMsg::TabSelected(name) => self.on_tab_selected(ctx, name),
380
394
  MainPanelMsg::ContextMenu(id, x, y) => self.on_context_menu(ctx, id, x, y),
@@ -43,7 +43,6 @@ pub mod rows_counter;
43
43
  pub mod settings_panel;
44
44
  pub mod status_bar;
45
45
  pub mod status_indicator;
46
- pub mod string_column_style;
47
46
  pub mod style;
48
47
  pub mod style_controls;
49
48
  pub mod type_icon;
@@ -19,10 +19,12 @@ use itertools::Itertools;
19
19
  use perspective_client::config::ViewConfig;
20
20
  use yew::prelude::*;
21
21
 
22
+ use crate::components::column_settings_sidebar::style_tab::alignment_field::AlignmentField;
23
+ use crate::components::column_settings_sidebar::style_tab::font_field::FontField;
22
24
  use crate::components::column_settings_sidebar::style_tab::primitive_field::{
23
25
  BoolField, ColorField, EnumField, NumberFieldPrimitive,
24
26
  };
25
- use crate::config::ControlSpec;
27
+ use crate::config::{Alignment, ControlSpec, FontToggle};
26
28
  use crate::presentation::Presentation;
27
29
  use crate::queries::get_plugin_config_schema;
28
30
  use crate::renderer::Renderer;
@@ -72,9 +74,10 @@ pub fn PluginTab(props: &PluginTabProps) -> Html {
72
74
  let schema = {
73
75
  let renderer = props.renderer.clone();
74
76
  let view_config = props.view_config.clone();
77
+ let plugin_config = props.plugin_config.clone();
75
78
  use_memo(
76
79
  (props.plugin_config.clone(), props.view_config.clone()),
77
- move |_| match get_plugin_config_schema(&renderer, &view_config) {
80
+ move |_| match get_plugin_config_schema(&renderer, &view_config, Some(&plugin_config)) {
78
81
  Ok(schema) => schema.fields,
79
82
  Err(error) => {
80
83
  tracing::error!("{}", error);
@@ -180,6 +183,58 @@ fn render_leaf(
180
183
  />
181
184
  })
182
185
  },
186
+ ControlSpec::Font {
187
+ key,
188
+ default,
189
+ size,
190
+ bold,
191
+ italic,
192
+ } => {
193
+ let current = raw_config
194
+ .get(&key)
195
+ .and_then(|v| v.as_str().map(|s| s.to_string()));
196
+ let toggle = |t: &FontToggle| raw_config.get(&t.key).and_then(|v| v.as_bool());
197
+ let size_current = size
198
+ .as_ref()
199
+ .and_then(|s| raw_config.get(&s.key))
200
+ .and_then(|v| v.as_f64());
201
+ let bold_current = bold.as_ref().and_then(toggle);
202
+ let italic_current = italic.as_ref().and_then(toggle);
203
+ Some(html! {
204
+ <FontField
205
+ field_key={key}
206
+ {default}
207
+ {current}
208
+ {size}
209
+ {size_current}
210
+ {bold}
211
+ {bold_current}
212
+ {italic}
213
+ {italic_current}
214
+ on_change={on_change.clone()}
215
+ />
216
+ })
217
+ },
218
+ ControlSpec::Alignment {
219
+ key,
220
+ default,
221
+ corners,
222
+ } => {
223
+ let current = raw_config
224
+ .get(&key)
225
+ .and_then(|v| v.as_str())
226
+ .and_then(Alignment::parse);
227
+
228
+ Some(html! {
229
+ <AlignmentField
230
+ field_key={key}
231
+ {default}
232
+ {corners}
233
+ {current}
234
+ on_change={on_change.clone()}
235
+ />
236
+ })
237
+ },
183
238
  ControlSpec::Bool { key, default } => {
184
239
  let current = raw_config.get(&key).and_then(|v| v.as_bool());
185
240
  Some(html! {
@@ -220,7 +275,6 @@ fn render_leaf(
220
275
  | ControlSpec::AggregateDepth
221
276
  | ControlSpec::NumberSeriesStyle { .. }
222
277
  | ControlSpec::DatetimeFormat { .. }
223
- | ControlSpec::StringFormat
224
278
  | ControlSpec::Symbols { .. }
225
279
  | ControlSpec::NumberFormat { .. }
226
280
  | ControlSpec::String { .. }
@@ -60,6 +60,10 @@ pub struct SettingsPanelProps {
60
60
  /// prop-driven instead of reading `Renderer` directly.
61
61
  pub plugin_config: PtrEqRc<serde_json::Map<String, serde_json::Value>>,
62
62
 
63
+ /// Snapshot of the active plugin's `columns_config` bucket, threaded
64
+ /// from `RendererProps` into `ColumnSelector`.
65
+ pub columns_config: PtrEqRc<ColumnConfigMap>,
66
+
63
67
  /// Column currently being dragged (if any) — threaded to show drag
64
68
  /// highlights without per-component `DragDrop` PubSub subscriptions.
65
69
  pub drag_column: Option<String>,
@@ -114,6 +118,7 @@ impl PartialEq for SettingsPanelProps {
114
118
  && self.plugin_static_config == rhs.plugin_static_config
115
119
  && self.view_config == rhs.view_config
116
120
  && self.plugin_config == rhs.plugin_config
121
+ && self.columns_config == rhs.columns_config
117
122
  && self.drag_column == rhs.drag_column
118
123
  && self.metadata == rhs.metadata
119
124
  && self.open_column_settings == rhs.open_column_settings
@@ -356,6 +361,7 @@ pub fn SettingsPanel(props: &SettingsPanelProps) -> Html {
356
361
  named_column_count={props.named_column_count}
357
362
  plugin_static_config={props.plugin_static_config.clone()}
358
363
  view_config={props.view_config.clone()}
364
+ columns_config={props.columns_config.clone()}
359
365
  drag_column={props.drag_column.clone()}
360
366
  metadata={props.metadata.clone()}
361
367
  selected_theme={props.selected_theme.clone()}
@@ -51,6 +51,8 @@ pub fn StatusIndicator(props: &StatusIndicatorProps) -> Html {
51
51
  && matches!(props.session_props.has_table, Some(TableLoadState::Loading))
52
52
  {
53
53
  StatusIconState::Loading
54
+ } else if matches!(props.session_props.has_table, Some(TableLoadState::Pending)) {
55
+ StatusIconState::Pending
54
56
  } else if props.update_count > 0 {
55
57
  StatusIconState::Updating
56
58
  } else if has_table_cells {
@@ -65,6 +67,7 @@ pub fn StatusIndicator(props: &StatusIndicatorProps) -> Html {
65
67
  StatusIconState::Normal => "connected",
66
68
  StatusIconState::Updating => "updating",
67
69
  StatusIconState::Loading => "loading",
70
+ StatusIconState::Pending => "pending",
68
71
  StatusIconState::Uninitialized => "uninitialized",
69
72
  };
70
73
 
@@ -107,6 +110,7 @@ pub fn StatusIndicator(props: &StatusIndicatorProps) -> Html {
107
110
  #[derive(Clone, Debug, PartialEq)]
108
111
  enum StatusIconState {
109
112
  Loading,
113
+ Pending,
110
114
  Updating,
111
115
  Errored(String, String, &'static str, bool),
112
116
  Normal,
@@ -235,6 +235,7 @@ impl PerspectiveViewer {
235
235
  None,
236
236
  update,
237
237
  client,
238
+ crate::session::MissingTable::Error,
238
239
  )
239
240
  .await?;
240
241
 
@@ -269,6 +270,7 @@ impl PerspectiveViewer {
269
270
  None,
270
271
  ViewerConfigInitial::new(table_name),
271
272
  client,
273
+ crate::session::MissingTable::Error,
272
274
  )
273
275
  .await?;
274
276
 
@@ -313,6 +315,7 @@ impl PerspectiveViewer {
313
315
  None,
314
316
  ViewerConfigInitial::new(table),
315
317
  Some(client),
318
+ crate::session::MissingTable::Error,
316
319
  )
317
320
  .await?;
318
321
 
@@ -95,6 +95,7 @@ impl PerspectiveViewer {
95
95
  plugin_static_config={self.renderer_props.config.clone()}
96
96
  {view_config}
97
97
  plugin_config={self.renderer_props.plugin_config.clone()}
98
+ columns_config={self.renderer_props.columns_config.clone()}
98
99
  {drag_column}
99
100
  metadata={metadata.clone()}
100
101
  open_column_settings={self.presentation_props.open_column_settings.clone()}
@@ -304,12 +304,21 @@ pub(super) fn create_active_subscriptions(
304
304
  }
305
305
  });
306
306
 
307
+ let cb_columns_config = ctx.link().callback({
308
+ let renderer = renderer.clone();
309
+ move |_: ColumnConfigMap| UpdateRenderer(Box::new(renderer.to_props(None)))
310
+ });
311
+
307
312
  let sub1 = renderer.plugin_changed.add_listener(cb_plugin);
308
313
  let sub2 = renderer
309
314
  .plugin_config_changed
310
315
  .add_listener(cb_plugin_config);
311
316
 
312
- vec![sub1, sub2]
317
+ let sub3 = renderer
318
+ .columns_config_changed
319
+ .add_listener(cb_columns_config);
320
+
321
+ vec![sub1, sub2, sub3]
313
322
  };
314
323
 
315
324
  let mut subscriptions = Vec::new();
@@ -74,6 +74,38 @@ pub enum ControlSpec {
74
74
  variants: Vec<EnumVariant>,
75
75
  default: String,
76
76
  },
77
+
78
+ /// A CSS `font-family` picked from the generic keywords plus the families
79
+ /// the host can enumerate, with optional size, bold and italic inputs
80
+ /// each bound to its own key.
81
+ Font {
82
+ key: String,
83
+ default: String,
84
+
85
+ #[serde(default, skip_serializing_if = "Option::is_none")]
86
+ size: Option<FontSize>,
87
+
88
+ #[serde(default, skip_serializing_if = "Option::is_none")]
89
+ bold: Option<FontToggle>,
90
+
91
+ #[serde(default, skip_serializing_if = "Option::is_none")]
92
+ italic: Option<FontToggle>,
93
+ },
94
+ /// A 3×3 anchor picker whose value is one of the nine [`Alignment`]
95
+ /// tokens.
96
+ Alignment {
97
+ key: String,
98
+
99
+ /// The cell shown as the unmodified value and elided from serialized
100
+ /// configs, or `None` when an unset key stands for something no cell
101
+ /// can show.
102
+ #[serde(default, skip_serializing_if = "Option::is_none")]
103
+ default: Option<Alignment>,
104
+
105
+ /// Only the four corner cells are selectable.
106
+ #[serde(default, skip_serializing_if = "std::ops::Not::not")]
107
+ corners: bool,
108
+ },
77
109
  Bool {
78
110
  key: String,
79
111
  default: bool,
@@ -125,7 +157,6 @@ pub enum ControlSpec {
125
157
  #[serde(default, skip_serializing_if = "Option::is_none")]
126
158
  default: Option<DatetimeFormatType>,
127
159
  },
128
- StringFormat,
129
160
  NumberSeriesStyle {
130
161
  default: NumberSeriesStyleDefaultConfig,
131
162
  },
@@ -147,6 +178,108 @@ pub enum ControlSpec {
147
178
  },
148
179
  }
149
180
 
181
+ /// One cell of a 3×3 anchor grid: a corner `top-left`/`top-right`/
182
+ /// `bottom-left`/`bottom-right`, an edge `top`/`left`/`right`/`bottom`, or
183
+ /// `center`.
184
+ #[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
185
+ #[serde(rename_all = "kebab-case")]
186
+ pub enum Alignment {
187
+ TopLeft,
188
+ Top,
189
+ TopRight,
190
+ Left,
191
+ Center,
192
+ Right,
193
+ BottomLeft,
194
+ Bottom,
195
+ BottomRight,
196
+ }
197
+
198
+ impl Alignment {
199
+ /// Every cell in row-major order.
200
+ pub const ALL: [Alignment; 9] = [
201
+ Alignment::TopLeft,
202
+ Alignment::Top,
203
+ Alignment::TopRight,
204
+ Alignment::Left,
205
+ Alignment::Center,
206
+ Alignment::Right,
207
+ Alignment::BottomLeft,
208
+ Alignment::Bottom,
209
+ Alignment::BottomRight,
210
+ ];
211
+ pub const CORNERS: [Alignment; 4] = [
212
+ Alignment::TopLeft,
213
+ Alignment::TopRight,
214
+ Alignment::BottomLeft,
215
+ Alignment::BottomRight,
216
+ ];
217
+
218
+ pub fn is_corner(self) -> bool {
219
+ Self::CORNERS.contains(&self)
220
+ }
221
+
222
+ /// The serialized token.
223
+ pub fn as_str(self) -> &'static str {
224
+ match self {
225
+ Alignment::TopLeft => "top-left",
226
+ Alignment::Top => "top",
227
+ Alignment::TopRight => "top-right",
228
+ Alignment::Left => "left",
229
+ Alignment::Center => "center",
230
+ Alignment::Right => "right",
231
+ Alignment::BottomLeft => "bottom-left",
232
+ Alignment::Bottom => "bottom",
233
+ Alignment::BottomRight => "bottom-right",
234
+ }
235
+ }
236
+
237
+ pub fn parse(src: &str) -> Option<Self> {
238
+ Self::ALL.into_iter().find(|x| x.as_str() == src)
239
+ }
240
+
241
+ /// `Top Left`-style text for accessible names.
242
+ pub fn humanized(self) -> String {
243
+ self.as_str()
244
+ .split('-')
245
+ .map(|word| {
246
+ let mut chars = word.chars();
247
+ match chars.next() {
248
+ Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
249
+ None => String::new(),
250
+ }
251
+ })
252
+ .collect::<Vec<_>>()
253
+ .join(" ")
254
+ }
255
+ }
256
+
257
+ /// One boolean style toggle of a [`ControlSpec::Font`] control: the
258
+ /// config key it writes and the value that counts as "not set".
259
+ #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
260
+ pub struct FontToggle {
261
+ pub key: String,
262
+ #[serde(default)]
263
+ pub default: bool,
264
+ }
265
+
266
+ /// The size input of a [`ControlSpec::Font`] control: a number (CSS px)
267
+ /// under its own key, elided from serialized configs at `default`.
268
+ #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
269
+ pub struct FontSize {
270
+ pub key: String,
271
+ pub default: f64,
272
+
273
+ #[serde(default, skip_serializing_if = "Option::is_none")]
274
+ pub min: Option<f64>,
275
+
276
+ #[serde(default, skip_serializing_if = "Option::is_none")]
277
+ pub max: Option<f64>,
278
+
279
+ #[serde(default, skip_serializing_if = "Option::is_none")]
280
+ pub step: Option<f64>,
281
+ }
282
+
150
283
  #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
151
284
  pub struct EnumVariant {
152
285
  pub value: String,
@@ -182,9 +315,7 @@ impl ColumnConfigSchema {
182
315
  fn is_format(spec: &ControlSpec) -> bool {
183
316
  matches!(
184
317
  spec,
185
- ControlSpec::NumberFormat { .. }
186
- | ControlSpec::DatetimeFormat { .. }
187
- | ControlSpec::StringFormat
318
+ ControlSpec::NumberFormat { .. } | ControlSpec::DatetimeFormat { .. }
188
319
  )
189
320
  }
190
321
 
@@ -230,6 +361,18 @@ impl ColumnConfigSchema {
230
361
  canonicalize_specs(fields);
231
362
  return !fields.is_empty();
232
363
  },
364
+ ControlSpec::Alignment {
365
+ key,
366
+ default: Some(default),
367
+ corners: true,
368
+ } if !default.is_corner() => {
369
+ tracing::error!(
370
+ "Dropping `{key}` — default `{}` is not a corner",
371
+ default.as_str()
372
+ );
373
+
374
+ return false;
375
+ },
233
376
  ControlSpec::Color { key, default } => (CssKind::Color, key, default),
234
377
  ControlSpec::Palette { key, default, .. } => (CssKind::Palette, key, default),
235
378
  ControlSpec::GradientStops { key, default, .. } => {
@@ -255,6 +398,16 @@ impl ColumnConfigSchema {
255
398
  self
256
399
  }
257
400
 
401
+ /// The variants of the [`ControlSpec::Enum`] owning `key`, if any.
402
+ pub fn enum_variants_of(&self, key: &str) -> Option<&[EnumVariant]> {
403
+ self.leaf_fields().into_iter().find_map(|spec| match spec {
404
+ ControlSpec::Enum {
405
+ key: k, variants, ..
406
+ } if k == key => Some(variants.as_slice()),
407
+ _ => None,
408
+ })
409
+ }
410
+
258
411
  /// The CSS kind of the control owning `key`, if it is CSS-valued.
259
412
  pub fn css_kind_of(&self, key: &str) -> Option<CssKind> {
260
413
  self.leaf_fields().into_iter().find_map(|spec| match spec {
@@ -275,12 +428,27 @@ impl ControlSpec {
275
428
  pub fn serialized_keys(&self) -> Vec<&str> {
276
429
  match self {
277
430
  ControlSpec::DatetimeFormat { .. } => vec!["date_format"],
278
- ControlSpec::StringFormat => vec!["format"],
431
+ ControlSpec::Font {
432
+ key,
433
+ size,
434
+ bold,
435
+ italic,
436
+ ..
437
+ } => [
438
+ Some(key.as_str()),
439
+ size.as_ref().map(|s| s.key.as_str()),
440
+ bold.as_ref().map(|t| t.key.as_str()),
441
+ italic.as_ref().map(|t| t.key.as_str()),
442
+ ]
443
+ .into_iter()
444
+ .flatten()
445
+ .collect(),
279
446
  ControlSpec::NumberSeriesStyle { .. } => vec!["chart_type", "stack"],
280
447
  ControlSpec::Symbols { .. } => vec!["symbols"],
281
448
  ControlSpec::NumberFormat { .. } => vec!["number_format"],
282
449
  ControlSpec::AggregateDepth => vec!["aggregate_depth"],
283
450
  ControlSpec::Enum { key, .. }
451
+ | ControlSpec::Alignment { key, .. }
284
452
  | ControlSpec::Bool { key, .. }
285
453
  | ControlSpec::Number { key, .. }
286
454
  | ControlSpec::String { key, .. }
@@ -396,13 +564,46 @@ mod tests {
396
564
  assert_eq!(flat.css_kind_of("stack"), grouped.css_kind_of("stack"));
397
565
  }
398
566
 
567
+ #[test]
568
+ fn enum_variants_see_through_groups() {
569
+ let mode = ControlSpec::Enum {
570
+ key: "fg_mode".to_owned(),
571
+ default: "color".to_owned(),
572
+ variants: vec![
573
+ EnumVariant {
574
+ value: "disabled".to_owned(),
575
+ label: None,
576
+ },
577
+ EnumVariant {
578
+ value: "color".to_owned(),
579
+ label: None,
580
+ },
581
+ ],
582
+ };
583
+
584
+ let grouped = ColumnConfigSchema {
585
+ fields: vec![group("color", vec![mode, flag("flag")])],
586
+ };
587
+
588
+ let values: Vec<&str> = grouped
589
+ .enum_variants_of("fg_mode")
590
+ .unwrap()
591
+ .iter()
592
+ .map(|v| v.value.as_str())
593
+ .collect();
594
+
595
+ assert_eq!(values, vec!["disabled", "color"]);
596
+ assert!(grouped.enum_variants_of("flag").is_none());
597
+ assert!(grouped.enum_variants_of("missing").is_none());
598
+ }
599
+
399
600
  #[test]
400
601
  fn format_controls_group_and_merge() {
401
602
  let schema = ColumnConfigSchema {
402
603
  fields: vec![
403
604
  ControlSpec::NumberFormat { default: None },
404
605
  flag("flag"),
405
- ControlSpec::StringFormat,
606
+ ControlSpec::DatetimeFormat { default: None },
406
607
  ],
407
608
  }
408
609
  .group_format_controls();
@@ -414,19 +615,45 @@ mod tests {
414
615
 
415
616
  assert_eq!(key, "format");
416
617
  assert!(matches!(fields[0], ControlSpec::NumberFormat { .. }));
417
- assert!(matches!(fields[1], ControlSpec::StringFormat));
618
+ assert!(matches!(fields[1], ControlSpec::DatetimeFormat { .. }));
418
619
  assert!(matches!(&schema.fields[1], ControlSpec::Bool { .. }));
419
620
 
420
621
  assert_eq!(
421
622
  schema.active_keys(),
422
623
  HashSet::from([
423
624
  "number_format".to_owned(),
424
- "format".to_owned(),
625
+ "date_format".to_owned(),
425
626
  "flag".to_owned()
426
627
  ])
427
628
  );
428
629
  }
429
630
 
631
+ #[test]
632
+ fn font_owns_its_size_and_toggle_keys() {
633
+ let spec = ControlSpec::Font {
634
+ key: "font_family".to_owned(),
635
+ default: "inherit".to_owned(),
636
+ size: Some(FontSize {
637
+ key: "font_size".to_owned(),
638
+ default: 12.0,
639
+ min: None,
640
+ max: None,
641
+ step: None,
642
+ }),
643
+ bold: Some(FontToggle {
644
+ key: "bold".to_owned(),
645
+ default: false,
646
+ }),
647
+ italic: None,
648
+ };
649
+
650
+ assert_eq!(spec.serialized_keys(), vec![
651
+ "font_family",
652
+ "font_size",
653
+ "bold"
654
+ ]);
655
+ }
656
+
430
657
  #[test]
431
658
  fn format_grouping_recurses_but_never_double_wraps() {
432
659
  let schema = ColumnConfigSchema {
@@ -536,6 +763,83 @@ mod tests {
536
763
  );
537
764
  }
538
765
 
766
+ #[test]
767
+ fn alignment_tokens_round_trip() {
768
+ for align in Alignment::ALL {
769
+ assert_eq!(Alignment::parse(align.as_str()), Some(align));
770
+ assert_eq!(serde_json::to_value(align).unwrap(), json!(align.as_str()));
771
+ }
772
+
773
+ for bad in ["middle", "left-top", "top-center", "middle-left", ""] {
774
+ assert_eq!(Alignment::parse(bad), None, "{bad}");
775
+ }
776
+
777
+ assert_eq!(Alignment::ALL.iter().filter(|x| x.is_corner()).count(), 4);
778
+ assert_eq!(Alignment::TopLeft.humanized(), "Top Left");
779
+ assert_eq!(Alignment::Center.humanized(), "Center");
780
+ }
781
+
782
+ #[test]
783
+ fn alignment_deserializes_with_optional_default_and_corners() {
784
+ let schema: ColumnConfigSchema = serde_json::from_value(json!({
785
+ "fields": [
786
+ { "kind": "Alignment", "key": "align" },
787
+ { "kind": "Alignment", "key": "legend_anchor", "default": "top-right", "corners": true }
788
+ ]
789
+ }))
790
+ .unwrap();
791
+
792
+ assert!(matches!(&schema.fields[0], ControlSpec::Alignment {
793
+ default: None,
794
+ corners: false,
795
+ ..
796
+ }));
797
+ assert!(matches!(&schema.fields[1], ControlSpec::Alignment {
798
+ default: Some(Alignment::TopRight),
799
+ corners: true,
800
+ ..
801
+ }));
802
+ assert_eq!(
803
+ schema.active_keys(),
804
+ HashSet::from(["align".to_owned(), "legend_anchor".to_owned()])
805
+ );
806
+ assert!(
807
+ serde_json::from_value::<ColumnConfigSchema>(json!({
808
+ "fields": [{ "kind": "Alignment", "key": "x", "default": "middle" }]
809
+ }))
810
+ .is_err()
811
+ );
812
+ }
813
+
814
+ #[test]
815
+ fn canonicalize_defaults_drops_non_corner_default_of_corners_field() {
816
+ let schema = ColumnConfigSchema {
817
+ fields: vec![
818
+ ControlSpec::Alignment {
819
+ key: "bad".to_owned(),
820
+ default: Some(Alignment::Top),
821
+ corners: true,
822
+ },
823
+ ControlSpec::Alignment {
824
+ key: "ok".to_owned(),
825
+ default: Some(Alignment::Top),
826
+ corners: false,
827
+ },
828
+ ControlSpec::Alignment {
829
+ key: "unset".to_owned(),
830
+ default: None,
831
+ corners: true,
832
+ },
833
+ ],
834
+ }
835
+ .canonicalize_defaults();
836
+
837
+ assert_eq!(
838
+ schema.active_keys(),
839
+ HashSet::from(["ok".to_owned(), "unset".to_owned()])
840
+ );
841
+ }
842
+
539
843
  #[test]
540
844
  fn canonicalize_defaults_recurses_and_drops_empty_groups() {
541
845
  let schema = ColumnConfigSchema {
@@ -10,13 +10,11 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- mod color_mode;
14
13
  mod custom;
15
14
  mod custom_format;
16
15
  mod simple;
17
16
  mod simple_format;
18
17
 
19
- pub use color_mode::*;
20
18
  pub use custom::*;
21
19
  pub use custom_format::*;
22
20
  use serde::{Deserialize, Serialize};
@@ -65,13 +63,4 @@ pub struct DatetimeColumnStyleConfig {
65
63
  #[serde(skip_serializing_if = "Option::is_none")]
66
64
  #[ts(optional, as = "Option<_>")]
67
65
  pub date_format: Option<DatetimeFormatType>,
68
-
69
- #[serde(default)]
70
- #[serde(skip_serializing_if = "DatetimeColorMode::is_none")]
71
- pub datetime_color_mode: DatetimeColorMode,
72
-
73
- #[serde(default)]
74
- #[serde(skip_serializing_if = "Option::is_none")]
75
- #[ts(skip)]
76
- pub color: Option<String>,
77
66
  }