@perspective-dev/viewer 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/dist/cdn/perspective-viewer.js +2 -2
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/monokai.css +1 -1
  9. package/dist/css/phosphor.css +1 -1
  10. package/dist/css/pro-dark.css +1 -1
  11. package/dist/css/pro.css +1 -1
  12. package/dist/css/solarized-dark.css +1 -1
  13. package/dist/css/solarized.css +1 -1
  14. package/dist/css/themes.css +1 -1
  15. package/dist/css/vaporwave.css +1 -1
  16. package/dist/docs/perspective-docs.json +1 -1
  17. package/dist/esm/perspective-viewer.d.ts +1 -0
  18. package/dist/esm/perspective-viewer.inline.js +9 -9
  19. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  20. package/dist/esm/perspective-viewer.js +4 -4
  21. package/dist/esm/perspective-viewer.js.map +2 -2
  22. package/dist/esm/plugin.d.ts +10 -0
  23. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  24. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  25. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  26. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  27. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  28. package/dist/wasm/perspective-viewer.d.ts +43 -36
  29. package/dist/wasm/perspective-viewer.js +194 -136
  30. package/dist/wasm/perspective-viewer.wasm +0 -0
  31. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  32. package/package.json +1 -1
  33. package/src/css/column-settings-panel.css +9 -5
  34. package/src/css/column-style.css +209 -31
  35. package/src/css/dom/select.css +2 -1
  36. package/src/css/form/code-editor.css +4 -4
  37. package/src/rust/agent/tools.rs +48 -17
  38. package/src/rust/components/column_selector/active_column.rs +2 -2
  39. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  40. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  41. package/src/rust/components/column_selector/config_selector.rs +8 -0
  42. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  43. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  44. package/src/rust/components/column_selector.rs +48 -4
  45. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  46. package/src/rust/components/column_settings_sidebar/style_tab.rs +262 -225
  47. package/src/rust/components/column_settings_sidebar.rs +88 -69
  48. package/src/rust/components/editable_header.rs +85 -144
  49. package/src/rust/components/expression_editor.rs +9 -16
  50. package/src/rust/components/form/code_editor.rs +16 -6
  51. package/src/rust/components/form/debug.rs +1 -0
  52. package/src/rust/components/form/mod.rs +3 -1
  53. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  54. package/src/rust/components/form/named_value_picker.rs +86 -0
  55. package/src/rust/components/form/palette_selector.rs +440 -0
  56. package/src/rust/components/main_panel/msg.rs +2 -2
  57. package/src/rust/components/main_panel/reconcile.rs +4 -4
  58. package/src/rust/components/plugin_tab.rs +4 -28
  59. package/src/rust/components/settings_panel.rs +25 -20
  60. package/src/rust/components/viewer/msg.rs +46 -7
  61. package/src/rust/components/viewer/panels.rs +11 -9
  62. package/src/rust/components/viewer/render.rs +8 -3
  63. package/src/rust/components/viewer/settings.rs +163 -23
  64. package/src/rust/components/viewer/snapshots.rs +46 -21
  65. package/src/rust/components/viewer/wiring.rs +14 -25
  66. package/src/rust/components/viewer.rs +12 -8
  67. package/src/rust/config/column_config_schema.rs +100 -41
  68. package/src/rust/config/css_scale.rs +882 -0
  69. package/src/rust/config/mod.rs +2 -0
  70. package/src/rust/config/options.rs +10 -0
  71. package/src/rust/config/viewer_config.rs +49 -55
  72. package/src/rust/config/workspace_config.rs +132 -5
  73. package/src/rust/custom_elements/viewer.rs +214 -155
  74. package/src/rust/custom_events.rs +14 -1
  75. package/src/rust/js/regular_layout.rs +3 -3
  76. package/src/rust/lib.rs +2 -0
  77. package/src/rust/presentation/column_locator.rs +19 -5
  78. package/src/rust/presentation.rs +192 -36
  79. package/src/rust/queries/column_locator.rs +74 -44
  80. package/src/rust/queries/get_viewer_config.rs +0 -3
  81. package/src/rust/queries/mod.rs +2 -0
  82. package/src/rust/queries/palette_set.rs +82 -0
  83. package/src/rust/queries/plugin_column_styles.rs +6 -2
  84. package/src/rust/renderer/dispatch.rs +16 -4
  85. package/src/rust/renderer/plugin_config.rs +309 -47
  86. package/src/rust/renderer.rs +15 -42
  87. package/src/rust/session/column_defaults_update.rs +15 -8
  88. package/src/rust/session/metadata.rs +5 -1
  89. package/src/rust/session/props.rs +1 -0
  90. package/src/rust/session.rs +9 -6
  91. package/src/rust/tasks/create_panel.rs +44 -57
  92. package/src/rust/tasks/edit_expression.rs +19 -23
  93. package/src/rust/tasks/edit_window.rs +8 -10
  94. package/src/rust/tasks/eject.rs +4 -1
  95. package/src/rust/tasks/pipeline.rs +2 -2
  96. package/src/rust/tasks/presize_panels.rs +44 -5
  97. package/src/rust/tasks/reset_all.rs +4 -4
  98. package/src/rust/tasks/restore_and_render.rs +17 -44
  99. package/src/rust/tasks/restore_panel.rs +11 -13
  100. package/src/rust/tasks/set_edit_mode.rs +3 -1
  101. package/src/rust/tasks/sync_update_panels.rs +11 -1
  102. package/src/rust/tasks/update_theme.rs +23 -18
  103. package/src/rust/utils/css_vars.rs +26 -0
  104. package/src/rust/utils/mod.rs +2 -0
  105. package/src/rust/utils/spawn.rs +1 -1
  106. package/src/rust/workspace.rs +435 -272
  107. package/src/svg/lock.svg +7 -0
  108. package/src/themes/botanical.css +10 -0
  109. package/src/themes/defaults.css +12 -0
  110. package/src/themes/dracula.css +17 -0
  111. package/src/themes/gruvbox-dark.css +20 -0
  112. package/src/themes/gruvbox.css +10 -0
  113. package/src/themes/icons.css +1 -0
  114. package/src/themes/monokai.css +22 -0
  115. package/src/themes/phosphor.css +5 -0
  116. package/src/themes/pro-dark.css +10 -0
  117. package/src/themes/solarized-dark.css +7 -0
  118. package/src/themes/solarized.css +7 -0
  119. package/src/themes/vaporwave.css +20 -0
  120. package/src/ts/perspective-viewer.ts +1 -0
  121. package/src/ts/plugin.ts +14 -0
  122. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  123. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  124. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  125. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  126. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  127. package/src/rust/components/form/color_range_selector.rs +0 -137
  128. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline0.js +0 -0
  129. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline1.js +0 -0
  130. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline2.js +0 -0
  131. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline3.js +0 -0
  132. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline4.js +0 -0
  133. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-39c3c3b28e3cbd48}/inline5.js +0 -0
@@ -84,11 +84,15 @@ extern "C" {
84
84
  #[wasm_bindgen(typescript_type = "GetClientOptions")]
85
85
  pub type JsGetClientOptions;
86
86
 
87
- /// `restoreWorkspace()` argument: a whole-element config update.
87
+ /// `restoreWorkspace()` argument: a workspace config update.
88
88
  #[wasm_bindgen(typescript_type = "WorkspaceConfigUpdate")]
89
89
  pub type JsWorkspaceConfigUpdate;
90
90
 
91
- /// `saveWorkspace()` return: a whole-element config.
91
+ /// `saveWorkspace()` options dict (`{ full_palette?: boolean }`).
92
+ #[wasm_bindgen(typescript_type = "SaveWorkspaceOptions")]
93
+ pub type JsSaveWorkspaceOptions;
94
+
95
+ /// `saveWorkspace()` return: a workspace config.
92
96
  #[wasm_bindgen(typescript_type = "Promise<WorkspaceConfig>")]
93
97
  pub type JsWorkspaceConfigPromise;
94
98
 
@@ -201,7 +205,8 @@ impl PerspectiveViewerElement {
201
205
  .map(|el| el.unchecked_into())
202
206
  }
203
207
 
204
- async fn workspace_config(this: Self) -> ApiResult<JsValue> {
208
+ async fn workspace_config(this: Self, full_palette: bool) -> ApiResult<JsValue> {
209
+ this.workspace.effects().settle().await;
205
210
  let mut panels: std::collections::BTreeMap<String, PanelViewerConfig> = Default::default();
206
211
  for id in &this.workspace.panel_ids() {
207
212
  let panel = this.workspace.panel(id).into_apierror()?;
@@ -216,6 +221,26 @@ impl PerspectiveViewerElement {
216
221
  panels.insert(id.as_str().to_owned(), config.panel);
217
222
  }
218
223
 
224
+ let mut palette = palette_set(&this.workspace, &this.presentation);
225
+ let mut referenced = std::collections::BTreeSet::new();
226
+ for (id, item) in styles_in_use(&this.workspace) {
227
+ let Some(name) = palette_name_for(&palette, item.kind, &item.literal) else {
228
+ continue;
229
+ };
230
+
231
+ if let Some(entry) = panels
232
+ .get_mut(id.as_str())
233
+ .and_then(|panel| panel.columns_config.get_mut(&item.column))
234
+ {
235
+ entry.insert(item.key, serde_json::Value::String(format_var_ref(&name)));
236
+ referenced.insert(name);
237
+ }
238
+ }
239
+
240
+ if !full_palette {
241
+ palette.retain(|name, _| referenced.contains(name));
242
+ }
243
+
219
244
  let active = this
220
245
  .presentation
221
246
  .is_settings_open()
@@ -240,6 +265,7 @@ impl PerspectiveViewerElement {
240
265
  .iter()
241
266
  .map(|id| id.as_str().to_owned())
242
267
  .collect(),
268
+ palette,
243
269
  })?)
244
270
  }
245
271
  }
@@ -521,7 +547,7 @@ impl PerspectiveViewerElement {
521
547
  renderer
522
548
  .clone()
523
549
  .render_task(|guard| async move {
524
- renderer.set_default_theme(presentation.get_default_theme_name().await);
550
+ seed_panel_theme(&presentation, &renderer).await;
525
551
  renderer.stamp_theme(None);
526
552
  let jstable = JsFuture::from(promise)
527
553
  .await
@@ -536,7 +562,7 @@ impl PerspectiveViewerElement {
536
562
  };
537
563
 
538
564
  if let Some(notify) = &notify {
539
- place_reserved(&workspace, notify);
565
+ place_reserved(&workspace, notify, true);
540
566
  }
541
567
 
542
568
  let _plugin = renderer.ensure_plugin_selected()?;
@@ -594,7 +620,35 @@ impl PerspectiveViewerElement {
594
620
  // commits already applied live (`commit_view_config`).
595
621
  let owned_window = session.take_pending_load(generation).is_some();
596
622
  let discard = if owned_window && notify.is_some() {
597
- workspace.take_reserved()
623
+ match workspace.take_reserved() {
624
+ Some(panel) => Some((panel, None)),
625
+ // The one-shot claim read: a table-less
626
+ // `restore` claimed the reservation, and no
627
+ // table has bound nor is pending — evict the
628
+ // panel `CREATE_REQUIRES_TABLE` forbids.
629
+ None if workspace
630
+ .resolve_claim()
631
+ .is_some_and(|has_table| !has_table)
632
+ && session.get_table().is_none()
633
+ && session.pending_table().is_none() =>
634
+ {
635
+ let evicted = renderer
636
+ .slot_name()
637
+ .map(PanelId::from)
638
+ .and_then(|id| workspace.remove_panel(&id));
639
+
640
+ if evicted.is_some()
641
+ && let Some(notify) = &notify
642
+ {
643
+ notify.emit(());
644
+ }
645
+
646
+ evicted.map(|panel| {
647
+ (panel, Some(ApiError::new(CREATE_REQUIRES_TABLE)))
648
+ })
649
+ },
650
+ None => None,
651
+ }
598
652
  } else {
599
653
  None
600
654
  };
@@ -613,13 +667,19 @@ impl PerspectiveViewerElement {
613
667
  Err(e) => {
614
668
  session.take_pending_load(generation);
615
669
  if let Some(notify) = &notify {
616
- place_reserved(&workspace, notify);
670
+ place_reserved(&workspace, notify, true);
617
671
  }
618
672
 
619
673
  session.set_error(false, e.clone()).await?;
620
674
  Err(e)
621
675
  },
622
- Ok(Some(panel)) => eject_panel(panel).await,
676
+ Ok(Some((panel, error))) => {
677
+ eject_panel(panel).await?;
678
+ match error {
679
+ Some(e) => Err(e),
680
+ None => Ok(()),
681
+ }
682
+ },
623
683
  Ok(None) => Ok(()),
624
684
  }
625
685
  }))
@@ -931,18 +991,18 @@ impl PerspectiveViewerElement {
931
991
  /// optional `{panel}` selector.
932
992
  ///
933
993
  /// If `panel` names no existing panel, a NEW panel is created with that id
934
- /// and the config restored into it (an upsert). As with a created panel,
935
- /// the element-level `settings`/`theme` fields are ignored in that case.
936
- /// Unlike [`Self::addPanel`], the argument is a PATCH, so a `table` is
937
- /// optional: creating without one yields a DEFERRED panel that the next
938
- /// [`Self::load`] binds. Such a panel renders but cannot be serialized —
939
- /// [`Self::save`] rejects with "Panel has no `table`" until it is bound.
994
+ /// and the config restored into it (an upsert). Creation REQUIRES a
995
+ /// `table` — the same rule [`Self::addPanel`] enforces in its argument
996
+ /// type — and a would-create call without one REJECTS before any state
997
+ /// (including `settings`) is applied: with no panel to target and no
998
+ /// `table`, the patch has no data arrival path. In particular, on an
999
+ /// element with zero panels every `restore` must carry a `table`.
940
1000
  ///
941
1001
  /// On an empty element with a pending [`Self::load`] whose payload is not
942
1002
  /// yet classified, the active-target form (no `panel`) instead claims and
943
1003
  /// restores into that load's reserved first panel — see [`Self::load`].
944
1004
  ///
945
- /// This restores a SINGLE panel; a whole-element config (with a `panels`
1005
+ /// This restores a SINGLE panel; a workspace config (with a `panels`
946
1006
  /// map) must be applied via [`Self::restoreWorkspace`] — its `panels` /
947
1007
  /// `layout` keys are ignored here.
948
1008
  ///
@@ -990,10 +1050,6 @@ impl PerspectiveViewerElement {
990
1050
  suppress_errors,
991
1051
  } = parse_options(options);
992
1052
 
993
- // `suppress_errors` failures reject the returned Promise WITHOUT
994
- // committing to the viewer's visible error state — for programmatic
995
- // callers (the agent's `set_view_config`) whose failed patches are
996
- // caller feedback, not user-facing faults.
997
1053
  let errors = if suppress_errors.unwrap_or_default() {
998
1054
  RestoreErrors::Suppress
999
1055
  } else {
@@ -1006,43 +1062,59 @@ impl PerspectiveViewerElement {
1006
1062
  let _effect = effect;
1007
1063
  let id = name.map(PanelId::from);
1008
1064
  let mut update = ViewerConfigUpdate::decode(&update)?;
1009
-
1010
- // `settings` is ELEMENT-level chrome, not panel state — it
1011
- // rides in this object only because the legacy single-panel
1012
- // `restore()` has always carried it. Apply it HERE and hand
1013
- // the panel pipeline a purely per-panel update: the
1014
- // panel-CREATING branch below converts to
1015
- // `ViewerConfigInitial`, which has no `settings` field at
1016
- // all, so a `restore()` against an empty element — the first
1017
- // call every embedding makes — would otherwise drop it
1018
- // silently. Applied BEFORE the panel work so the plugin
1019
- // draws once, already at its final size.
1020
- // BOTH halves move together, or the flag and the chrome
1021
- // disagree: `set_settings_before_open` writes the persisted
1022
- // `is_settings_open` (what `save()` reports) and the host
1023
- // `settings` attribute, while `ToggleSettingsComplete`
1024
- // performs the component's own toggle. `restore_and_render`
1025
- // used to do the first for the panel-UPDATING path only —
1026
- // which the creating path never reaches, since
1027
- // `ViewerConfigInitial` has no `settings` field to carry.
1028
1065
  let settings = std::mem::replace(&mut update.settings, OptionalUpdate::Missing);
1029
- if !matches!(settings, OptionalUpdate::Missing) {
1030
- if let OptionalUpdate::Update(open) = settings {
1031
- this.presentation.set_settings_before_open(open);
1032
- }
1066
+ enum Target {
1067
+ Existing { panel: Panel, active: bool },
1068
+ Claimed(Panel),
1069
+ Create(Box<ViewerConfigInitial>),
1070
+ }
1071
+
1072
+ let notify = this.layout_changed_notify();
1073
+ let target = match this.workspace.panel_or_active(id.as_ref()) {
1074
+ // An existing (or the active) panel — update it in place.
1075
+ Some(panel) => {
1076
+ let active = this.workspace.active_id().as_ref() == Some(&panel.id);
1077
+ Target::Existing { panel, active }
1078
+ },
1079
+ None => {
1080
+ let has_table = matches!(&update.table, OptionalUpdate::Update(_));
1081
+ match id
1082
+ .is_none()
1083
+ .then(|| place_reserved(&this.workspace, &notify, has_table))
1084
+ .flatten()
1085
+ {
1086
+ Some(panel) => Target::Claimed(panel),
1087
+ None => Target::Create(Box::new(ViewerConfigInitial::try_from(
1088
+ std::mem::take(&mut update),
1089
+ )?)),
1090
+ }
1091
+ },
1092
+ };
1033
1093
 
1034
- let (sender, receiver) = channel::<()>();
1094
+ if !matches!(settings, OptionalUpdate::Missing) {
1095
+ // Through `ToggleSettingsInit` — the SAME full choreography
1096
+ // the toolbar toggle drives (presize every visible plugin
1097
+ // to its post-toggle box, then the exactness-finalizer
1098
+ // resize) — NOT the bare `ToggleSettingsComplete` leaf,
1099
+ // which only re-renders the pane. The pane is the outer
1100
+ // `SplitPanel` (no `before-resize` event) and the host box
1101
+ // is unchanged, so a leaf-only toggle left every canvas
1102
+ // plugin CSS-stretched at its old backing size.
1103
+ //
1104
+ // No `set_settings_before_open` here: `is_settings_open` is
1105
+ // Init's toggle-vs-no-op DISPATCH state, so pre-writing the
1106
+ // target makes every call resolve as a no-op. Init owns the
1107
+ // write, as it does for the toolbar and `toggleConfig`.
1108
+ let (sender, receiver) = channel::<ApiResult<JsValue>>();
1035
1109
  this.root.borrow().as_ref().into_apierror()?.send_message(
1036
- PerspectiveViewerMsg::ToggleSettingsComplete(settings, sender),
1110
+ PerspectiveViewerMsg::ToggleSettingsInit(Some(settings), false, Some(sender)),
1037
1111
  );
1038
1112
 
1039
- receiver.await.unwrap_or_log();
1113
+ receiver.await.map_err(|_| ApiError::new("Cancelled"))??;
1040
1114
  }
1041
1115
 
1042
- match this.workspace.panel_or_active(id.as_ref()) {
1043
- // An existing (or the active) panel — update it in place.
1044
- Some(panel) => {
1045
- let active = this.workspace.active_id().as_ref() == Some(&panel.id);
1116
+ match target {
1117
+ Target::Existing { panel, active } => {
1046
1118
  restore_panel(
1047
1119
  &panel.session,
1048
1120
  &panel.renderer,
@@ -1054,61 +1126,30 @@ impl PerspectiveViewerElement {
1054
1126
  )
1055
1127
  .await
1056
1128
  },
1057
- // No existing panel matched. The active-target form
1058
- // (`panel: None`) CLAIMS a pending `load()`'s reserved panel
1059
- // — placing it and restoring into it — so a `restore` fired
1060
- // right after an unawaited `load(promise)` configures the
1061
- // panel that load's payload will bind, per the call-site
1062
- // ordering contract in [`Self::load`]. Named upserts and
1063
- // reservation-less elements create a fresh panel instead,
1064
- // routing through the shared `create_panel`
1065
- // (`RestoreMode::Fresh`) pipeline so the new panel's id is
1066
- // the requested `panel`.
1067
- None => {
1068
- let notify = this.layout_changed_notify();
1069
- let claimed = id
1070
- .is_none()
1071
- .then(|| place_reserved(&this.workspace, &notify))
1072
- .flatten();
1073
- match claimed {
1074
- Some(panel) => {
1075
- restore_panel(
1076
- &panel.session,
1077
- &panel.renderer,
1078
- &this.presentation,
1079
- &this.workspace,
1080
- RestoreMode::Existing { active: true },
1081
- update,
1082
- errors,
1083
- )
1084
- .await
1085
- },
1086
- None => {
1087
- // The panel-creating upsert. `restore`'s
1088
- // argument is a PATCH, so a `table` is
1089
- // optional here as it is everywhere else on
1090
- // this path: an update carrying none creates
1091
- // a deferred panel, which the next `load()`
1092
- // binds (it targets `active_panel()` before
1093
- // reserving one of its own). The
1094
- // table-required rule belongs to the
1095
- // CREATION types — `addPanel`,
1096
- // `restoreWorkspace`'s `panels`, the agent's
1097
- // `add_panel` — which enforce it in their
1098
- // own signatures.
1099
- create_panel(
1100
- &this.elem,
1101
- &this.presentation,
1102
- &this.workspace,
1103
- &notify,
1104
- id,
1105
- update,
1106
- None,
1107
- )
1108
- .await?;
1109
- Ok(())
1110
- },
1111
- }
1129
+ Target::Claimed(panel) => {
1130
+ restore_panel(
1131
+ &panel.session,
1132
+ &panel.renderer,
1133
+ &this.presentation,
1134
+ &this.workspace,
1135
+ RestoreMode::Existing { active: true },
1136
+ update,
1137
+ errors,
1138
+ )
1139
+ .await
1140
+ },
1141
+ Target::Create(config) => {
1142
+ create_panel(
1143
+ &this.elem,
1144
+ &this.presentation,
1145
+ &this.workspace,
1146
+ &notify,
1147
+ id,
1148
+ *config,
1149
+ None,
1150
+ )
1151
+ .await?;
1152
+ Ok(())
1112
1153
  },
1113
1154
  }
1114
1155
  });
@@ -1116,8 +1157,8 @@ impl PerspectiveViewerElement {
1116
1157
  js_sys::Promise::from(fut).unchecked_into()
1117
1158
  }
1118
1159
 
1119
- /// Restore the ENTIRE element from a whole-element
1120
- /// [`WorkspaceConfigUpdate`] (`{version, active?, layout, panels, ...}`) —
1160
+ /// Restore the ENTIRE element from a [`WorkspaceConfigUpdate`]
1161
+ /// (`{version, active?, layout, panels, ...}`) —
1121
1162
  /// the multi-panel counterpart of [`Self::restore`]. Every existing panel
1122
1163
  /// is replaced by the `panels` entries, and the layout tree + master/detail
1123
1164
  /// cross-filter state re-applied. Unlike [`Self::restore`], this never
@@ -1226,6 +1267,7 @@ impl PerspectiveViewerElement {
1226
1267
  let PanelOptions { panel: name } = parse_options(options);
1227
1268
  let this = self.clone();
1228
1269
  let fut = ApiFuture::new(async move {
1270
+ this.workspace.effects().settle().await;
1229
1271
  let panel = this.resolve_panel(name)?;
1230
1272
  let viewer_config = panel
1231
1273
  .renderer
@@ -1241,11 +1283,12 @@ impl PerspectiveViewerElement {
1241
1283
  js_sys::Promise::from(fut).unchecked_into()
1242
1284
  }
1243
1285
 
1244
- /// Save the ENTIRE element to a whole-element [`WorkspaceConfig`]
1245
- /// (`{version, active?, layout, panels, ...}`) — the multi-panel
1246
- /// counterpart of [`Self::save`]. Unlike [`Self::save`] (which emits a
1247
- /// single `ViewerConfig` for one panel), this ALWAYS emits the
1248
- /// whole-element format, restorable via [`Self::restoreWorkspace`].
1286
+ /// Save the ENTIRE element to a [`WorkspaceConfig`]
1287
+ /// (`{version, active?, layout, panels, palette?, ...}`) — the
1288
+ /// multi-panel counterpart of [`Self::save`]. Unlike [`Self::save`]
1289
+ /// (which emits a single `ViewerConfig` for one panel), this ALWAYS
1290
+ /// emits the workspace format, restorable via
1291
+ /// [`Self::restoreWorkspace`].
1249
1292
  ///
1250
1293
  /// # JavaScript Examples
1251
1294
  ///
@@ -1253,9 +1296,13 @@ impl PerspectiveViewerElement {
1253
1296
  /// const token = await viewer.saveWorkspace();
1254
1297
  /// await viewer.restoreWorkspace(token);
1255
1298
  /// ```
1256
- pub fn saveWorkspace(&self) -> JsWorkspaceConfigPromise {
1299
+ pub fn saveWorkspace(
1300
+ &self,
1301
+ options: Option<JsSaveWorkspaceOptions>,
1302
+ ) -> JsWorkspaceConfigPromise {
1303
+ let SaveWorkspaceOptions { full_palette } = parse_options(options);
1257
1304
  let this = self.clone();
1258
- let fut = ApiFuture::new(Self::workspace_config(this));
1305
+ let fut = ApiFuture::new(Self::workspace_config(this, full_palette.unwrap_or(false)));
1259
1306
  js_sys::Promise::from(fut).unchecked_into()
1260
1307
  }
1261
1308
 
@@ -1385,9 +1432,10 @@ impl PerspectiveViewerElement {
1385
1432
  /// Reset a panel's `ViewerConfig` to its data-relative default.
1386
1433
  ///
1387
1434
  /// Without a `panel`, this is ELEMENT-LEVEL: EVERY panel is reset and the
1388
- /// cross-filter overlay cleared (symmetric with whole-element
1389
- /// [`Self::save`] / [`Self::restore`]). With `{panel}`, only that panel is
1390
- /// reset — the other panels and the overlay are left untouched.
1435
+ /// cross-filter overlay cleared (symmetric with
1436
+ /// [`Self::saveWorkspace`] / [`Self::restoreWorkspace`]). With `{panel}`,
1437
+ /// only that panel is reset — the other panels and the overlay are left
1438
+ /// untouched.
1391
1439
  ///
1392
1440
  /// # Arguments
1393
1441
  ///
@@ -1638,17 +1686,15 @@ impl PerspectiveViewerElement {
1638
1686
  /// ```
1639
1687
  #[wasm_bindgen]
1640
1688
  pub fn restyleElement(&self) -> ApiFuture<JsValue> {
1641
- clone!(self.workspace, self.presentation);
1689
+ clone!(self.workspace);
1642
1690
  let effect = workspace.effects().guard();
1643
1691
  ApiFuture::new(async move {
1644
1692
  let _effect = effect;
1645
- let default = presentation.get_default_theme_name().await;
1646
1693
  for panel in workspace
1647
1694
  .panel_ids()
1648
1695
  .into_iter()
1649
1696
  .filter_map(|id| workspace.panel(&id))
1650
1697
  {
1651
- panel.renderer.set_default_theme(default.clone());
1652
1698
  panel.renderer.restyle_all().await?;
1653
1699
  }
1654
1700
 
@@ -1656,6 +1702,21 @@ impl PerspectiveViewerElement {
1656
1702
  })
1657
1703
  }
1658
1704
 
1705
+ #[wasm_bindgen]
1706
+ pub fn getThemes(&self) -> ApiFuture<JsValue> {
1707
+ clone!(self.presentation);
1708
+ ApiFuture::new(async move {
1709
+ let x = presentation
1710
+ .get_available_themes()
1711
+ .await?
1712
+ .iter()
1713
+ .cloned()
1714
+ .collect::<Vec<_>>();
1715
+
1716
+ Ok(JsValue::from(x))
1717
+ })
1718
+ }
1719
+
1659
1720
  /// Set the available theme names available in the status bar UI.
1660
1721
  ///
1661
1722
  /// Calling [`Self::resetThemes`] may cause the current theme to switch,
@@ -1676,46 +1737,41 @@ impl PerspectiveViewerElement {
1676
1737
  let effect = workspace.effects().guard();
1677
1738
  ApiFuture::new(async move {
1678
1739
  let _effect = effect;
1679
- let themes: Option<Vec<String>> = themes
1680
- .unwrap_or_default()
1681
- .iter()
1682
- .map(|x| x.as_string())
1683
- .collect();
1740
+ // `None` (re-parse the document) must survive the conversion —
1741
+ // mapping BEFORE defaulting is what keeps that branch reachable
1742
+ // from JavaScript at all.
1743
+ let themes: Option<Vec<String>> = match themes {
1744
+ None => None,
1745
+ Some(themes) => themes.iter().map(|x| x.as_string()).collect(),
1746
+ };
1684
1747
 
1685
- let theme_name = presentation.get_selected_theme_name().await;
1686
- presentation.reset_available_themes(themes).await;
1748
+ let previous = presentation.active_theme_name_sync();
1749
+ let active = presentation.reset_themes(themes).await?;
1687
1750
  let available = presentation.get_available_themes().await?;
1688
- let reset_theme = available
1689
- .iter()
1690
- .find(|y| theme_name.as_ref() == Some(y))
1691
- .cloned();
1692
-
1693
- presentation.set_theme_name(reset_theme.as_deref()).await?;
1694
- let new_default = presentation.get_default_theme_name().await;
1695
1751
  for panel in workspace
1696
1752
  .panel_ids()
1697
1753
  .into_iter()
1698
1754
  .filter_map(|id| workspace.panel(&id))
1699
1755
  {
1700
- // Availability applies to PANELS too: a panel pinned to a
1701
- // theme outside the new set follows the host — clear the pin
1702
- // so it renders (and `save`s) the new registry default, the
1703
- // same keep-if-available-else-default rule applied to the
1704
- // host selection above.
1705
- if panel
1706
- .renderer
1707
- .theme()
1708
- .is_some_and(|t| !available.contains(&t))
1709
- {
1710
- panel.renderer.set_theme(None);
1711
- }
1712
-
1713
- panel.renderer.set_default_theme(new_default.clone());
1714
- if panel.renderer.needs_restyle() {
1715
- panel.renderer.restyle_all().await?;
1756
+ let theme = panel.renderer.theme();
1757
+
1758
+ // A panel follows the host only when it was TRACKING it (it
1759
+ // holds the host's previous theme, which is how every panel
1760
+ // born without an explicit one starts — and what keeps the
1761
+ // active panel and the host in agreement), or when its own
1762
+ // theme has left the registry. A panel explicitly set to a
1763
+ // different, still-available theme is untouched: re-ordering
1764
+ // alone must repaint nothing.
1765
+ let stale = theme.as_ref().is_none_or(|x| !available.contains(x));
1766
+ if (stale || theme == previous) && theme != active {
1767
+ panel.renderer.set_theme(active.clone());
1768
+ if panel.renderer.needs_restyle() {
1769
+ panel.renderer.restyle_all().await?;
1770
+ }
1716
1771
  }
1717
1772
  }
1718
1773
 
1774
+ presentation.publish_theme_config().await?;
1719
1775
  Ok(JsValue::UNDEFINED)
1720
1776
  })
1721
1777
  }
@@ -1770,7 +1826,7 @@ impl PerspectiveViewerElement {
1770
1826
  let force = force.map(SettingsUpdate::Update);
1771
1827
  let (sender, receiver) = channel::<ApiResult<wasm_bindgen::JsValue>>();
1772
1828
  root.borrow().as_ref().into_apierror()?.send_message(
1773
- PerspectiveViewerMsg::ToggleSettingsInit(force, Some(sender)),
1829
+ PerspectiveViewerMsg::ToggleSettingsInit(force, true, Some(sender)),
1774
1830
  );
1775
1831
 
1776
1832
  receiver.await.map_err(|_| JsValue::from("Cancelled"))?
@@ -1824,8 +1880,6 @@ impl PerspectiveViewerElement {
1824
1880
  let notify = self.layout_changed_notify();
1825
1881
  ApiFuture::new(async move {
1826
1882
  let _effect = effect;
1827
- // The table-required guarantee is HERE, in the argument type
1828
- // and this decode — `create_panel` itself takes a patch.
1829
1883
  let config = ViewerConfigInitial::decode(&config)?;
1830
1884
  let id = create_panel(
1831
1885
  &elem,
@@ -1833,7 +1887,7 @@ impl PerspectiveViewerElement {
1833
1887
  &workspace,
1834
1888
  &notify,
1835
1889
  None,
1836
- config.into(),
1890
+ config,
1837
1891
  None,
1838
1892
  )
1839
1893
  .await?;
@@ -1931,7 +1985,12 @@ impl PerspectiveViewerElement {
1931
1985
  let _effect = effect;
1932
1986
  let panel = this.resolve_panel(name)?;
1933
1987
  let was_active = this.workspace.active_id().as_ref() == Some(&panel.id);
1934
- let locator = get_column_locator(&panel.session.metadata(), Some(column_name));
1988
+ let target = {
1989
+ let config = panel.session.get_view_config();
1990
+ let metadata = panel.session.metadata();
1991
+ classify_column(&column_name, &config, &metadata)
1992
+ .map(|_| crate::presentation::ColumnSettingsTarget::Column(column_name))
1993
+ };
1935
1994
  if !was_active {
1936
1995
  this.root.borrow().as_ref().into_apierror()?.send_message(
1937
1996
  PerspectiveViewerMsg::SetActivePanel(panel.id.as_str().to_owned(), None),
@@ -1941,7 +2000,7 @@ impl PerspectiveViewerElement {
1941
2000
  let (sender, receiver) = channel::<()>();
1942
2001
  this.root.borrow().as_ref().into_apierror()?.send_message(
1943
2002
  PerspectiveViewerMsg::OpenColumnSettings {
1944
- locator,
2003
+ target,
1945
2004
  sender: Some(sender),
1946
2005
  toggle: was_active,
1947
2006
  },
@@ -150,6 +150,10 @@ fn dispatch_config_update(
150
150
  renderer: &Renderer,
151
151
  presentation: &Presentation,
152
152
  ) {
153
+ if session.get_table().is_none() && session.pending_table().is_none() {
154
+ return;
155
+ }
156
+
153
157
  clone!(session, renderer, presentation);
154
158
  let elem = elem.clone();
155
159
  let tracker = session.clone();
@@ -218,7 +222,16 @@ pub fn wire_element_events(
218
222
 
219
223
  let settings_sub = presentation.settings_open_changed.add_listener({
220
224
  clone!(elem, presentation, workspace);
221
- move |open: bool| {
225
+ move |(open, announce): (bool, bool)| {
226
+ // A silent toggle (`restore`/`restoreWorkspace`) is announced
227
+ // by its own view-config commit dispatch — emitting here too
228
+ // would double-fire `perspective-config-update`, the first
229
+ // carrying the intermediate config (see
230
+ // `PresentationHandle::settings_open_changed`).
231
+ if !announce {
232
+ return;
233
+ }
234
+
222
235
  dispatch_event(&elem, "toggle-settings", open).unwrap();
223
236
  if let Some(panel) = workspace.active_panel() {
224
237
  dispatch_config_update(&elem, &panel.session, &panel.renderer, &presentation);
@@ -235,9 +235,9 @@ impl Layout {
235
235
  }
236
236
 
237
237
  /// Rewrite every panel id through `f`, returning the remapped tree. Ids for
238
- /// which `f` returns `None` are left unchanged. Used by whole-element
239
- /// `restore`, which recreates panels under fresh (collision-free) ids and
240
- /// must point the saved layout tree at them.
238
+ /// which `f` returns `None` are left unchanged. Used by `restoreWorkspace`,
239
+ /// which recreates panels under fresh (collision-free) ids and must point
240
+ /// the saved layout tree at them.
241
241
  pub fn remap(&self, f: &impl Fn(&str) -> Option<String>) -> Layout {
242
242
  match self {
243
243
  Layout::Split(split) => Layout::Split(SplitLayout {
package/src/rust/lib.rs CHANGED
@@ -95,6 +95,7 @@ export type * from "../../src/ts/ts-rs/WorkspaceConfigUpdate.d.ts";
95
95
  export type * from "../../src/ts/ts-rs/ExportMethod.d.ts";
96
96
  export type * from "../../src/ts/ts-rs/PanelOptions.d.ts";
97
97
  export type * from "../../src/ts/ts-rs/RestoreOptions.d.ts";
98
+ export type * from "../../src/ts/ts-rs/SaveWorkspaceOptions.d.ts";
98
99
  export type * from "../../src/ts/ts-rs/ClientOptions.d.ts";
99
100
  export type * from "../../src/ts/ts-rs/ExportOptions.d.ts";
100
101
  export type * from "../../src/ts/ts-rs/GetTableOptions.d.ts";
@@ -109,6 +110,7 @@ export type * from "../../src/ts/ts-rs/FormatMode.d.ts";
109
110
  import type {GetTableOptions} from "../../src/ts/ts-rs/GetTableOptions.d.ts";
110
111
  import type {PanelOptions} from "../../src/ts/ts-rs/PanelOptions.d.ts";
111
112
  import type {RestoreOptions} from "../../src/ts/ts-rs/RestoreOptions.d.ts";
113
+ import type {SaveWorkspaceOptions} from "../../src/ts/ts-rs/SaveWorkspaceOptions.d.ts";
112
114
  import type {ExportOptions} from "../../src/ts/ts-rs/ExportOptions.d.ts";
113
115
  import type {GetClientOptions} from "../../src/ts/ts-rs/GetClientOptions.d.ts";
114
116
  import type {ClientOptions} from "../../src/ts/ts-rs/ClientOptions.d.ts";