@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
@@ -187,7 +187,7 @@ impl SessionMetadata {
187
187
  }
188
188
 
189
189
  pub fn get_expression_columns(&self) -> impl Iterator<Item = &'_ String> {
190
- try {
190
+ let mut columns = try {
191
191
  self.as_ref()?
192
192
  .expr_meta
193
193
  .as_ref()?
@@ -197,6 +197,10 @@ impl SessionMetadata {
197
197
  }
198
198
  .into_iter()
199
199
  .flatten()
200
+ .collect::<Vec<_>>();
201
+
202
+ columns.sort_unstable();
203
+ columns.into_iter()
200
204
  }
201
205
 
202
206
  /// Returns the full original expression `String` for an expression alias.
@@ -145,6 +145,7 @@ impl SessionProps {
145
145
  ) {
146
146
  config_update.set_update_column_defaults(
147
147
  &self.metadata,
148
+ &self.config,
148
149
  &self.all_columns().into_iter().map(Some).collect::<Vec<_>>(),
149
150
  config_static,
150
151
  )
@@ -719,9 +719,11 @@ impl Session {
719
719
  config_static: &PluginStaticConfig,
720
720
  ) {
721
721
  use self::column_defaults_update::*;
722
+ let config = self.get_view_config();
722
723
  config_update.set_update_column_defaults(
723
724
  &self.metadata(),
724
- &self.get_view_config().columns,
725
+ &config,
726
+ &config.columns,
725
727
  config_static,
726
728
  )
727
729
  }
@@ -737,7 +739,11 @@ impl Session {
737
739
  config_static: &PluginStaticConfig,
738
740
  ) {
739
741
  use self::column_defaults_update::*;
740
- config_update.set_update_rollup_defaults(&self.metadata(), config_static)
742
+ config_update.set_update_rollup_defaults(
743
+ &self.metadata(),
744
+ &self.get_view_config(),
745
+ config_static,
746
+ )
741
747
  }
742
748
 
743
749
  /// Apply a `ViewConfigUpdate` to the live config — the ONLY view-config
@@ -889,7 +895,7 @@ impl Session {
889
895
  .pending_dispatches
890
896
  .set(self.0.pending_dispatches.get() + 1);
891
897
  let session = self.clone();
892
- ApiFuture::spawn(async move {
898
+ ApiFuture::spawn_named("config-update-dispatch", async move {
893
899
  let result = fut.await;
894
900
  let remaining = session.0.pending_dispatches.get() - 1;
895
901
  session.0.pending_dispatches.set(remaining);
@@ -1165,9 +1171,6 @@ impl Session {
1165
1171
  let old = self.borrow_mut().view_sub.take();
1166
1172
  ApiFuture::spawn(old.delete());
1167
1173
  self.borrow_mut().view_sub = Some(sub);
1168
- // REBUILD: a `View` was genuinely constructed and bound — the one
1169
- // place `view_created` may fire, and the one place the `FreshView`
1170
- // full-draw witness is minted from a rebuild.
1171
1174
  self.view_created.emit(());
1172
1175
  self.commit_reconciled.emit(());
1173
1176
  match self.get_view() {
@@ -20,7 +20,7 @@ use crate::renderer::*;
20
20
  use crate::session::{ResetOptions, Session};
21
21
  use crate::tasks::*;
22
22
  use crate::utils::*;
23
- use crate::workspace::{Panel, PanelId, Workspace};
23
+ use crate::workspace::{Panel, PanelId, PanelPhase, Workspace};
24
24
  use crate::*;
25
25
 
26
26
  /// Build the full set of subscriptions a [`Panel`] owns for its lifetime: its
@@ -70,29 +70,19 @@ fn wire_panel_render_sub(session: &Session, renderer: &Renderer) -> Subscription
70
70
  }
71
71
 
72
72
  /// Create a new independent panel (own `Session` + `Renderer` + id) and
73
- /// restore `update` into it. Shared by `addPanel`, `restore`'s
74
- /// create-if-missing upsert, and `restorePanel`. `id` is the panel's id —
75
- /// provided when restoring into a specific named slot, or `None` to
76
- /// generate a fresh one. `theme` is stripped (element-level, not
77
- /// per-panel) and `client` — or the element's default client when `None` —
78
- /// is bound so the config's `table` resolves against it.
79
- ///
80
- /// A `table` is OPTIONAL here: an update with none creates a deferred
81
- /// panel that a later `load()` binds, which is the pre-existing
82
- /// `restore`-then-`load` contract and the same state the reserved
83
- /// `load()` path produces. The routes where a table-less panel WOULD be
84
- /// permanently blank require one in their own argument types instead —
85
- /// `addPanel`'s [`ViewerConfigInitial`], `WorkspaceConfigUpdate`'s
86
- /// `panels` entries, and the agent's `add_panel` — so the guarantee sits
87
- /// at the boundary that owns it rather than here, which would sweep
88
- /// `restore`'s patch in with them.
73
+ /// restore `config` into it. Shared by `addPanel` and `restore`'s
74
+ /// create-if-missing upsert. `id` is the panel's id — provided when
75
+ /// restoring into a specific named slot, or `None` to generate a fresh
76
+ /// one. `theme` is stripped (element-level, not per-panel) and `client` —
77
+ /// or the element's default client when `None` — is bound so the config's
78
+ /// `table` resolves against it.
89
79
  pub(crate) async fn create_panel(
90
80
  elem: &HtmlElement,
91
81
  presentation: &Presentation,
92
82
  workspace: &Workspace,
93
83
  notify: &Callback<()>,
94
84
  id: Option<PanelId>,
95
- update: ViewerConfigUpdate,
85
+ config: ViewerConfigInitial,
96
86
  client: Option<perspective_client::Client>,
97
87
  ) -> ApiResult<PanelId> {
98
88
  let (id, session, renderer, update) = create_panel_model(
@@ -100,36 +90,25 @@ pub(crate) async fn create_panel(
100
90
  presentation,
101
91
  workspace,
102
92
  id,
103
- update,
93
+ config.into(),
104
94
  client,
105
- Placement::Placed,
95
+ Placement::Staged,
106
96
  );
107
97
 
108
98
  stamp_global_overlay(workspace, &id, &session);
109
- // STAGE the panel (model-first): `MainPanel` renders its plugin slot
110
- // into a hidden pre-sized wrapper — not a layout cell — so the restore
111
- // below completes its first draw invisibly at (near-)final size. The
112
- // promote below then re-renders, and `MainPanel::reconcile` inserts an
113
- // ALREADY-DRAWN panel: the one layout transition reveals content,
114
- // never a blank frame.
115
- workspace.stage_panel(&id);
116
99
  notify.emit(());
117
100
 
118
- // The staging deadline: a slow restore promotes EARLY (placed, still
119
- // loading — the pre-staging progressive reveal) rather than leaving
120
- // the layout without feedback. Races the completion promote below on
121
- // the staged flag; only the winner re-renders.
122
- {
101
+ ApiFuture::spawn({
123
102
  clone!(workspace, id, notify);
124
- ApiFuture::spawn(async move {
103
+ async move {
125
104
  set_timeout(STAGING_DEADLINE_MS).await?;
126
- if workspace.clear_staged(&id) {
105
+ if workspace.promote(&id) {
127
106
  notify.emit(());
128
107
  }
129
108
 
130
109
  Ok(())
131
- });
132
- }
110
+ }
111
+ });
133
112
 
134
113
  // A fresh panel is never the active one, so it needs no `root` for the
135
114
  // (active-only) settings-sidebar sequencing.
@@ -146,7 +125,7 @@ pub(crate) async fn create_panel(
146
125
 
147
126
  // Promote on completion AND error — a failed restore's error state
148
127
  // must become visible too.
149
- if workspace.clear_staged(&id) {
128
+ if workspace.promote(&id) {
150
129
  notify.emit(());
151
130
  }
152
131
 
@@ -156,10 +135,17 @@ pub(crate) async fn create_panel(
156
135
 
157
136
  /// Where [`create_panel_model`] registers the new panel model.
158
137
  pub(crate) enum Placement {
159
- /// Into the placed panel set ([`Workspace::insert_panel`]) — a live,
160
- /// visible panel from the start.
138
+ /// Into the placed panel set at [`PanelPhase::Placed`] — a live, visible
139
+ /// panel from the start (`restoreWorkspace` mounts panels directly at
140
+ /// their saved layout positions).
161
141
  Placed,
162
142
 
143
+ /// Into the placed panel set at [`PanelPhase::Staging`] — withheld from
144
+ /// the layout while its first draw completes in the hidden staging
145
+ /// wrapper, then promoted ([`Workspace::promote`]) by restore completion
146
+ /// or the [`STAGING_DEADLINE_MS`] deadline, whichever wins.
147
+ Staged,
148
+
163
149
  /// Into the reservation slot ([`Workspace::reserve_panel`]) — a pending
164
150
  /// `load()`'s first panel, held out of the placed set until it is
165
151
  /// claimed ([`place_reserved`]) or discarded.
@@ -180,32 +166,23 @@ pub(crate) fn create_panel_model(
180
166
  client: Option<perspective_client::Client>,
181
167
  placement: Placement,
182
168
  ) -> (PanelId, Session, Renderer, ViewerConfigUpdate) {
183
- // Authored-theme boot: the FIRST panel of an empty element with no explicit
184
- // theme adopts the element's `theme` attribute (a one-time initial
185
- // selection; the attribute is otherwise an active-panel mirror). Captured
186
- // before `insert_panel` makes the workspace non-empty.
187
- let boot_theme = (workspace.is_empty() && matches!(update.theme, OptionalUpdate::Missing))
188
- .then(|| elem.get_attribute("theme"))
189
- .flatten();
190
-
191
169
  let session = Session::new();
192
170
  let renderer = Renderer::new(elem);
193
171
  let id = id.unwrap_or_else(|| workspace.generate_id());
194
172
  renderer.set_slot_name(id.as_str());
195
- renderer.set_default_theme(presentation.default_theme_name_sync());
196
173
  let subs = wire_panel_subs(elem, presentation, &session, &renderer);
197
174
  let panel = Panel::new(id.clone(), session.clone(), renderer.clone(), subs);
198
175
  match placement {
199
- Placement::Placed => workspace.insert_panel(panel),
176
+ Placement::Placed => workspace.insert_panel(panel, PanelPhase::Placed),
177
+ Placement::Staged => workspace.insert_panel(panel, PanelPhase::Staging),
200
178
  Placement::Reserved => workspace.reserve_panel(panel),
201
179
  }
202
180
 
203
181
  update.settings = OptionalUpdate::Missing;
204
- if let OptionalUpdate::Update(theme) = &update.theme {
205
- renderer.set_theme(Some(theme.clone()));
206
- } else if let Some(theme) = boot_theme {
207
- renderer.set_theme(Some(theme));
208
- }
182
+ renderer.set_theme(match &update.theme {
183
+ OptionalUpdate::Update(theme) => Some(theme.clone()),
184
+ _ => presentation.active_theme_name_sync(),
185
+ });
209
186
 
210
187
  update.theme = OptionalUpdate::Missing;
211
188
  if let Some(client) = client.or_else(|| workspace.default_client()) {
@@ -221,8 +198,18 @@ pub(crate) fn create_panel_model(
221
198
  (id, session, renderer, update)
222
199
  }
223
200
 
224
- pub(crate) fn place_reserved(workspace: &Workspace, notify: &Callback<()>) -> Option<Panel> {
225
- let panel = workspace.claim_reserved()?;
201
+ /// Claim the pending `load()` reservation into the placed set
202
+ /// ([`Workspace::claim_reserved`]), stamping the global-filter overlay and
203
+ /// notifying the root. `has_table` is whether the claimant carried a
204
+ /// `table`, recorded atomically with the claim — pass `true` when `load()`
205
+ /// itself places its reservation (a `Table` payload, or error surfacing),
206
+ /// which is not a restore claim and must never arm the epilogue's eviction.
207
+ pub(crate) fn place_reserved(
208
+ workspace: &Workspace,
209
+ notify: &Callback<()>,
210
+ has_table: bool,
211
+ ) -> Option<Panel> {
212
+ let panel = workspace.claim_reserved(has_table)?;
226
213
  stamp_global_overlay(workspace, &panel.id, &panel.session);
227
214
  notify.emit(());
228
215
  Some(panel)
@@ -230,7 +217,7 @@ pub(crate) fn place_reserved(workspace: &Workspace, notify: &Callback<()>) -> Op
230
217
 
231
218
  /// Tear down a [`Panel`] already removed from the [`Workspace`]: dispose its
232
219
  /// renderer (slot-scoped plugin + light-DOM cleanup) and eject its table.
233
- /// Shared by the root's `ClosePanel` handler and whole-element `restore`'s
220
+ /// Shared by the root's `ClosePanel` handler and `restoreWorkspace`'s
234
221
  /// batch replacement of the pre-existing panel set.
235
222
  pub(crate) fn eject_panel(panel: Panel) -> ApiFuture<()> {
236
223
  let was_errored = panel.session.is_errored();
@@ -18,14 +18,22 @@ use perspective_client::clone;
18
18
  use perspective_client::config::{Expression, ViewConfigUpdate};
19
19
 
20
20
  use super::apply_and_render;
21
- use crate::presentation::{ColumnLocator, ColumnSettingsTab, OpenColumnSettings, Presentation};
21
+ use crate::presentation::{
22
+ ColumnSettingsTab, ColumnSettingsTarget, OpenColumnSettings, Presentation,
23
+ };
22
24
  use crate::renderer::Renderer;
23
25
  use crate::session::Session;
24
26
  use crate::*;
25
27
 
26
- /// Replace the expression at `old_name` with `new_expr` and re-render. After
27
- /// the render completes, opens the column-settings drawer on the renamed
28
- /// expression.
28
+ fn reopen(presentation: &Presentation, name: String) {
29
+ presentation.set_open_column_settings(Some(OpenColumnSettings {
30
+ target: Some(ColumnSettingsTarget::Column(name)),
31
+ tab: Some(ColumnSettingsTab::Attributes),
32
+ }));
33
+ }
34
+
35
+ /// Replace the expression at `old_name` with `new_expr` and re-render,
36
+ /// moving the column-settings drawer onto the renamed expression.
29
37
  pub fn update_expr(
30
38
  session: &Session,
31
39
  renderer: &Renderer,
@@ -39,25 +47,21 @@ pub fn update_expr(
39
47
  .to_props()
40
48
  .create_replace_expression_update(&old_name, &new_expr);
41
49
 
42
- apply_and_render(&session, &renderer, update)?.await?;
43
- presentation.set_open_column_settings(Some(OpenColumnSettings {
44
- locator: Some(ColumnLocator::Expression(new_expr.name.to_string())),
45
- tab: Some(ColumnSettingsTab::Attributes),
46
- }));
47
-
50
+ let run = apply_and_render(&session, &renderer, update)?;
51
+ reopen(&presentation, new_expr.name.to_string());
52
+ run.await?;
48
53
  Ok(())
49
54
  });
50
55
  }
51
56
 
52
- /// Insert `expr` into the session's expressions and re-render. After the
53
- /// render completes, opens the column-settings drawer on the new expression.
57
+ /// Insert `expr` into the session's expressions and re-render, moving the
58
+ /// column-settings drawer onto the new expression.
54
59
  pub fn save_expr(
55
60
  session: &Session,
56
61
  renderer: &Renderer,
57
62
  presentation: &Presentation,
58
63
  expr: Expression,
59
64
  ) -> ApiResult<()> {
60
- let presentation = presentation.clone();
61
65
  let expr_name: String = expr.name.clone().into();
62
66
  let task = {
63
67
  let mut serde_exprs = session.get_view_config().expressions.clone();
@@ -68,16 +72,8 @@ pub fn save_expr(
68
72
  })
69
73
  }?;
70
74
 
71
- ApiFuture::spawn(async move {
72
- task.await?;
73
- presentation.set_open_column_settings(Some(OpenColumnSettings {
74
- locator: Some(ColumnLocator::Expression(expr_name)),
75
- tab: Some(ColumnSettingsTab::Attributes),
76
- }));
77
-
78
- Ok(())
79
- });
80
-
75
+ reopen(presentation, expr_name);
76
+ ApiFuture::spawn(task);
81
77
  Ok(())
82
78
  }
83
79
 
@@ -14,14 +14,16 @@ use perspective_client::clone;
14
14
  use perspective_client::config::{Filter, ViewConfigUpdate, WindowSpec};
15
15
 
16
16
  use super::apply_and_render;
17
- use crate::presentation::{ColumnLocator, ColumnSettingsTab, OpenColumnSettings, Presentation};
17
+ use crate::presentation::{
18
+ ColumnSettingsTab, ColumnSettingsTarget, OpenColumnSettings, Presentation,
19
+ };
18
20
  use crate::renderer::Renderer;
19
21
  use crate::session::Session;
20
22
  use crate::*;
21
23
 
22
24
  fn reopen(presentation: &Presentation, name: String) {
23
25
  presentation.set_open_column_settings(Some(OpenColumnSettings {
24
- locator: Some(ColumnLocator::Window(name)),
26
+ target: Some(ColumnSettingsTarget::Column(name)),
25
27
  tab: Some(ColumnSettingsTab::Window),
26
28
  }));
27
29
  }
@@ -36,7 +38,6 @@ pub fn save_window(
36
38
  name: String,
37
39
  spec: WindowSpec,
38
40
  ) -> ApiResult<()> {
39
- let presentation = presentation.clone();
40
41
  let task = {
41
42
  let mut windows = session.get_view_config().windows.clone();
42
43
  windows.insert(name.clone(), spec);
@@ -46,12 +47,8 @@ pub fn save_window(
46
47
  })
47
48
  }?;
48
49
 
49
- ApiFuture::spawn(async move {
50
- task.await?;
51
- reopen(&presentation, name);
52
- Ok(())
53
- });
54
-
50
+ reopen(presentation, name);
51
+ ApiFuture::spawn(task);
55
52
  Ok(())
56
53
  }
57
54
 
@@ -134,8 +131,9 @@ pub fn update_window(
134
131
  update
135
132
  };
136
133
 
137
- apply_and_render(&session, &renderer, update)?.await?;
134
+ let run = apply_and_render(&session, &renderer, update)?;
138
135
  reopen(&presentation, new_name);
136
+ run.await?;
139
137
  Ok(())
140
138
  });
141
139
  }
@@ -23,11 +23,14 @@ use crate::workspace::Workspace;
23
23
  /// Tear down the entire viewer: dispose EVERY panel's engines (emit
24
24
  /// `table_unloaded`, destroy the renderer under its own draw lock, eject the
25
25
  /// session), then drop the Yew root once. Fans out over all panels — not just
26
- /// the seed — so panels added via `addPanel` or a whole-element `restore` don't
26
+ /// the seed — so panels added via `addPanel` or `restoreWorkspace` don't
27
27
  /// leak their `View`/`Table` + plugin when the element is deleted or ejected.
28
28
  pub fn delete_all<T: Component>(workspace: &Workspace, root: &Root<T>) -> ApiFuture<()> {
29
29
  clone!(workspace, root);
30
30
  ApiFuture::new(async move {
31
+ // A staged-but-unapplied `restoreWorkspace` layout tree must not
32
+ // outlive the panels it names.
33
+ workspace.take_pending_layout();
31
34
  let panels = workspace
32
35
  .take_reserved()
33
36
  .into_iter()
@@ -285,12 +285,12 @@ pub(crate) async fn locked_run(
285
285
 
286
286
  let view_config_snapshot = session.get_view_config().clone();
287
287
  let plugin_config_changed =
288
- renderer.update_plugin_config(&view_config_snapshot, spec.plugin_config);
288
+ renderer.update_plugin_config(&view_config_snapshot, spec.plugin_config)?;
289
289
  let columns_config_changed = renderer.update_columns_configs(
290
290
  &view_config_snapshot,
291
291
  &session,
292
292
  spec.columns_config,
293
- );
293
+ )?;
294
294
 
295
295
  let changed = plugin_config_changed || columns_config_changed;
296
296
  if changed || plugin_swapped {
@@ -131,11 +131,14 @@ pub async fn presize_visible_panels_grown(
131
131
  presize_visible_panels_scaled(workspace, elem, width_ratio, height_ratio).await
132
132
  }
133
133
 
134
- /// Pre-size each visible plugin to its SHRUNK post-open cell *before* the
135
- /// settings pane mounts (P2), using the open-state geometry deltas cached at
136
- /// the last close (`(layout_area.w − mpc.w, main_column.h − mpc.h)` — the pane
137
- /// and divider width and the docked status-bar height, both stable across a
138
- /// close/open cycle since the pane width persists in the override).
134
+ /// Pre-size each visible plugin to its cell SHRUNK by `(delta_w, delta_h)` —
135
+ /// GROWN when a delta is negative — *before* the layout change commits. Used
136
+ /// by the settings-pane open (P2, deltas `(layout_area.w − mpc.w,
137
+ /// main_column.h − mpc.h)` cached at the last close — the pane and divider
138
+ /// width and the docked status-bar height, both stable across a close/open
139
+ /// cycle since the pane width persists in the override) and by the
140
+ /// column-settings pin toggle and docked-drawer mount/unmount (signed delta
141
+ /// measured live at toggle time).
139
142
  pub async fn presize_visible_panels_open(
140
143
  workspace: &Workspace,
141
144
  elem: &web_sys::HtmlElement,
@@ -199,6 +202,7 @@ async fn presize_visible_panels_scaled(
199
202
  return StagedPresents::default();
200
203
  }
201
204
 
205
+ let mpc = shadow_rect(elem, "#main_panel_container");
202
206
  let mut last_chrome: Option<(f64, f64)> = None;
203
207
  let targets = workspace
204
208
  .panels()
@@ -222,6 +226,23 @@ async fn presize_visible_panels_scaled(
222
226
 
223
227
  last_chrome = chrome.or(last_chrome);
224
228
  let (cw, ch) = last_chrome.unwrap_or(super::CHROME_FALLBACK);
229
+
230
+ // Per axis, chrome can never exceed the container's
231
+ // non-plugin remainder: with no sibling panel on that axis,
232
+ // `mpc − box` IS the exact chrome, while the frame-margin
233
+ // measurement overcounts (margins that overlap container
234
+ // padding) — the source of a ~2px target miss that pushed
235
+ // every commit past the plugin transport's ±0.5px resize
236
+ // dedupe, costing an extra corrective render. With siblings
237
+ // the remainder is large and the measured chrome wins.
238
+ let (cw, ch) = match &mpc {
239
+ Some(mpc) => (
240
+ cw.min((mpc.width() - plugin_box.width()).max(0.0)),
241
+ ch.min((mpc.height() - plugin_box.height()).max(0.0)),
242
+ ),
243
+ None => (cw, ch),
244
+ };
245
+
225
246
  let w = ((plugin_box.width() + cw) * width_ratio - cw).max(0.0);
226
247
  let h = ((plugin_box.height() + ch) * height_ratio - ch).max(0.0);
227
248
  Some((panel, w, h))
@@ -300,6 +321,24 @@ pub fn measure_settings_open_deltas(elem: &web_sys::HtmlElement) -> Option<(f64,
300
321
  ))
301
322
  }
302
323
 
324
+ /// The signed `#main_panel_container` width delta a column-settings pin
325
+ /// toggle will cause: the docked `#modal_panel`'s flex box spans its drawer
326
+ /// child plus the overlapping divider (`drawer.right − divider.left`, the
327
+ /// same span in both modes since `.pinned` reparents nothing) — PINNING
328
+ /// takes it from the main column (positive, shrink), UNPINNING releases it
329
+ /// back (negative, grow). `None` when the drawer isn't mounted.
330
+ pub fn measure_column_settings_pin_delta(
331
+ elem: &web_sys::HtmlElement,
332
+ is_pinned: bool,
333
+ ) -> Option<f64> {
334
+ let drawer = shadow_rect(elem, "#modal_panel > .split-panel-child:first-child")?;
335
+ let divider_left = shadow_rect(elem, "#modal_panel > .split-panel-divider")
336
+ .map(|r| r.left())
337
+ .unwrap_or_else(|| drawer.left());
338
+ let width = (drawer.right() - divider_left).max(0.0);
339
+ Some(if is_pinned { -width } else { width })
340
+ }
341
+
303
342
  /// The frame chrome a plugin's grid cell has that the plugin itself doesn't
304
343
  /// occupy, as `(width, height)` px: margin + border + titlebar. Measured live
305
344
  /// from a `regular-layout-frame` and its plugin — robust to theme/CSS changes —
@@ -61,11 +61,11 @@ pub fn reset_all(
61
61
 
62
62
  presentation.reset_available_themes(None).await;
63
63
  if all {
64
- // Clear this panel's own per-panel theme so it reverts to inheriting
65
- // the (reset-to-default) host theme — `reset_theme` only resets the
66
- // host, which an explicitly-themed panel would otherwise override.
67
- renderer.set_theme(None);
64
+ // Put this panel back on the registry default CONCRETELY —
65
+ // `reset_theme` only resets the host, which an explicitly-themed
66
+ // panel would otherwise keep overriding.
68
67
  presentation.reset_theme().await?;
68
+ renderer.set_theme(presentation.get_default_theme_name().await);
69
69
  }
70
70
 
71
71
  // For `all = true`, route the bucket clears through `restore_and_render`'s
@@ -18,6 +18,7 @@ use futures::Future;
18
18
  use perspective_client::clone;
19
19
 
20
20
  use super::pipeline::{RunOrigin, RunSpec, locked_run};
21
+ use super::update_theme::seed_panel_theme;
21
22
  use crate::config::{OptionalUpdate, ViewerConfigUpdate};
22
23
  use crate::presentation::Presentation;
23
24
  use crate::renderer::Renderer;
@@ -70,11 +71,6 @@ pub fn restore_and_render(
70
71
  session.set_title(None);
71
72
  }
72
73
 
73
- // Mirror a config-carried theme onto the host attribute (the shared
74
- // chrome). The restyle a theme change requires is owned by the
75
- // MUTATION SITES (`restorePanel`'s own-theme tail, the theme-picker
76
- // task), not here — this run's own draw below already stamps the new
77
- // effective theme before the plugin's first style read.
78
74
  match theme_name {
79
75
  OptionalUpdate::SetDefault => {
80
76
  let current_name = presentation.get_selected_theme_name().await;
@@ -83,59 +79,31 @@ pub fn restore_and_render(
83
79
  }
84
80
  },
85
81
  OptionalUpdate::Update(x) => {
86
- // No pre-resolution gate: `set_theme_name` stamps the host
87
- // attribute SYNCHRONOUSLY before its registry await and
88
- // no-ops on literal equality itself. The old
89
- // `get_selected_theme_name().await` guard was precisely
90
- // the former-theme window on a cold registry — the host
91
- // held the old attribute until stylesheet parsing (and
92
- // everything queued behind it) resolved.
93
82
  presentation.set_theme_name(Some(&x)).await?;
94
83
  },
95
84
  _ => {},
96
85
  };
97
86
 
98
- // Resolve the target plugin here (pure — needed now for
99
- // `set_update_column_defaults`), but COMMIT it only inside the locked
100
- // run below, atomically with the view rebind it belongs to. No swap
101
- // intent may exist outside that run: an unrelated run that wins the
102
- // lock first (e.g. a `table_updated` redraw) must observe either the
103
- // fully-old or fully-new world, never a staged half of this one.
104
87
  let resolved_plugin = renderer.resolve_plugin_update(&plugin);
105
88
  if let Some((_, metadata)) = &resolved_plugin {
106
89
  session.set_update_column_defaults(&mut view_config, metadata);
107
90
  } else {
108
- // Same-plugin (or plugin-less) restore: `resolve_plugin_update`
109
- // returns `None`, but the plugin-advised `group_rollup_mode`
110
- // must STILL be enforced against the active plugin's metadata —
111
- // `restorePanel`'s table-change reset wipes the committed mode,
112
- // and nothing else on this path would restore it, leaving a
113
- // flat-only chart (Treemap / Sunburst) rendering rollup
114
- // subtotal rows. Rollup only; the full column-defaults pass is
115
- // reserved for plugin swaps, where `columns` genuinely needs
116
- // re-defaulting.
117
- session.set_update_rollup_defaults(&mut view_config, &renderer.metadata());
91
+ let metadata = if renderer.active_plugin().is_none() {
92
+ renderer
93
+ .resolve_plugin_update(&OptionalUpdate::SetDefault)
94
+ .map(|(_, metadata)| metadata)
95
+ .unwrap_or_else(|| renderer.metadata())
96
+ } else {
97
+ renderer.metadata()
98
+ };
99
+
100
+ session.set_update_rollup_defaults(&mut view_config, &metadata);
118
101
  }
119
102
 
120
103
  let plugin_idx = resolved_plugin.map(|(idx, _)| idx);
121
-
122
- // The config COMMIT: synchronous, validated, atomic (I1/I4). Under
123
- // I2/I3 committing before the lock is safe — whichever queued run
124
- // snapshots next picks it up, and this run's own snapshot (taken
125
- // inside the lock below) can only be this commit or fresher.
126
104
  session.commit_view_config(view_config)?;
127
-
128
- // Spinner accounting (RAII): held to the end of this restore —
129
- // INCLUDING the deferred-draw exit (no table yet → no
130
- // `bind_snapshot`), which under the old edge-counted scheme
131
- // stranded the `StatusIndicator` spinner permanently.
132
105
  let _run_token = session.begin_config_run();
133
-
134
- // Awaits theme-registry init, so the stamp below can never observe a
135
- // pre-init (empty) theme set on a cold first load. Seeds this
136
- // panel's renderer default-theme cache, which every locked draw
137
- // stamps the effective theme from.
138
- renderer.set_default_theme(presentation.get_default_theme_name().await);
106
+ seed_panel_theme(&presentation, &renderer).await;
139
107
  locked_run(&session, &renderer, RunSpec {
140
108
  origin,
141
109
  plugin_idx,
@@ -146,6 +114,11 @@ pub fn restore_and_render(
146
114
  })
147
115
  .await?;
148
116
 
117
+ if renderer.needs_restyle() {
118
+ renderer.restyle_all().await?;
119
+ }
120
+
121
+ presentation.publish_theme_config().await?;
149
122
  Ok(())
150
123
  })
151
124
  }