@perspective-dev/viewer 4.5.0 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cdn/perspective-viewer.js +1 -1
  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/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/perspective-viewer.inline.js +1 -1
  24. package/dist/esm/perspective-viewer.inline.js.map +2 -2
  25. package/dist/esm/perspective-viewer.js +1 -1
  26. package/dist/esm/perspective-viewer.js.map +2 -2
  27. package/dist/wasm/perspective-viewer.d.ts +13 -13
  28. package/dist/wasm/perspective-viewer.js +87 -82
  29. package/dist/wasm/perspective-viewer.wasm +0 -0
  30. package/dist/wasm/perspective-viewer.wasm.d.ts +13 -13
  31. package/package.json +1 -1
  32. package/src/css/column-settings-panel.css +9 -3
  33. package/src/css/column-style.css +9 -1
  34. package/src/css/dom/checkbox.css +2 -2
  35. package/src/css/viewer.css +79 -1
  36. package/src/rust/components/column_selector/active_column.rs +3 -0
  37. package/src/rust/components/column_selector/inactive_column.rs +2 -1
  38. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +1 -0
  39. package/src/rust/components/column_settings_sidebar.rs +1 -0
  40. package/src/rust/components/containers/dragdrop_list.rs +27 -0
  41. package/src/rust/components/editable_header.rs +15 -0
  42. package/src/rust/components/status_bar.rs +1 -1
  43. package/src/rust/components/viewer.rs +78 -2
  44. package/src/rust/renderer.rs +72 -18
  45. package/src/rust/tasks/reset_all.rs +38 -18
  46. package/src/rust/tasks/restore_and_render.rs +23 -12
  47. package/src/rust/tasks/send_column_config.rs +3 -2
  48. package/src/rust/tasks/send_plugin_config.rs +3 -2
  49. package/src/rust/tasks/update_and_render.rs +11 -4
  50. package/src/svg/checkbox-checked-icon.svg +1 -1
  51. package/src/svg/checkbox-unchecked-icon.svg +1 -1
  52. package/src/themes/icons.css +2 -0
  53. package/src/themes/intl/de.css +1 -0
  54. package/src/themes/intl/es.css +1 -0
  55. package/src/themes/intl/fr.css +1 -0
  56. package/src/themes/intl/ja.css +1 -0
  57. package/src/themes/intl/pt.css +1 -0
  58. package/src/themes/intl/zh.css +1 -0
  59. package/src/themes/intl.css +1 -0
  60. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-3cd58f0374935772}/inline0.js +0 -0
  61. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-3cd58f0374935772}/inline1.js +0 -0
  62. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-3cd58f0374935772}/inline2.js +0 -0
  63. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-3cd58f0374935772}/inline3.js +0 -0
  64. /package/dist/wasm/snippets/{perspective-viewer-39ab7da3ca157861 → perspective-viewer-3cd58f0374935772}/inline4.js +0 -0
@@ -16,10 +16,88 @@
16
16
  --psp--color: #ff0000;
17
17
  }
18
18
 
19
+ /* Shift-key affordance: while the user holds Shift, any mask-image icon
20
+ * tagged `.shift-alt-icon` recolors to advertise that a Shift-modified
21
+ * action is available on the underlying control. `!important` is required
22
+ * to beat per-icon ID-scoped `background-color` rules. */
23
+ :host(.shift-active) .shift-alt-icon {
24
+ background-color: var(
25
+ --shift-active--color,
26
+ var(--psp-datagrid--pos-cell--color, #1078d1)
27
+ ) !important;
28
+ }
29
+
30
+ :host(.shift-active) {
31
+ #sub-columns {
32
+ .is_column_active.toggle-mode {
33
+ -webkit-mask-image: var(--psp-icon--radio-off--mask-image);
34
+ mask-image: var(--psp-icon--radio-off--mask-image);
35
+
36
+ &:before {
37
+ content: var(--psp-icon--radio-off--mask-image);
38
+ }
39
+
40
+ &:hover {
41
+ -webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
42
+ mask-image: var(--psp-icon--radio-hover--mask-image);
43
+ }
44
+ }
45
+
46
+ .is_column_active.select-mode {
47
+ -webkit-mask-image: var(--psp-icon--checkbox-off--mask-image);
48
+ mask-image: var(--psp-icon--checkbox-off--mask-image);
49
+
50
+ &:before {
51
+ content: var(--psp-icon--checkbox-off--mask-image);
52
+ }
53
+
54
+ &:hover {
55
+ -webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
56
+ mask-image: var(--psp-icon--checkbox-hover--mask-image);
57
+ }
58
+ }
59
+ }
60
+
61
+ #active-columns {
62
+ .is_column_active.toggle-mode {
63
+ -webkit-mask-image: var(--psp-icon--radio-on--mask-image);
64
+ mask-image: var(--psp-icon--radio-on--mask-image);
65
+
66
+ &:before {
67
+ content: var(--psp-icon--radio-on--mask-image);
68
+ }
69
+
70
+ &:not(.required):hover {
71
+ -webkit-mask-image: var(--psp-icon--radio-hover--mask-image);
72
+ mask-image: var(--psp-icon--radio-hover--mask-image);
73
+ }
74
+ }
75
+
76
+ .is_column_active.select-mode {
77
+ -webkit-mask-image: var(--psp-icon--checkbox-on--mask-image);
78
+ mask-image: var(--psp-icon--checkbox-on--mask-image);
79
+
80
+ &:before {
81
+ content: var(--psp-icon--checkbox-on--mask-image);
82
+ }
83
+
84
+ &:not(.required):hover {
85
+ -webkit-mask-image: var(--psp-icon--checkbox-hover--mask-image);
86
+ mask-image: var(--psp-icon--checkbox-hover--mask-image);
87
+ }
88
+ }
89
+ }
90
+ }
91
+
19
92
  ::slotted(*) {
20
93
  pointer-events: var(--override-content-pointer-events);
21
94
  }
22
95
 
96
+ :host input[type="number"]::-webkit-inner-spin-button {
97
+ transform: scale(1.5); /* Makes the arrows 50% larger */
98
+ filter: invert(1);
99
+ }
100
+
23
101
  :host .sidebar_close_button {
24
102
  position: absolute;
25
103
  top: 0;
@@ -398,13 +476,13 @@
398
476
  cursor: pointer;
399
477
  padding: 6px 8px;
400
478
  font-size: var(--label--font-size, 0.75em);
479
+ text-transform: uppercase;
401
480
  flex: 0 1 100px;
402
481
  background-color: #00000020;
403
482
  border-bottom: 1px solid var(--psp-inactive--color);
404
483
  color: var(--psp-inactive--color);
405
484
  margin-left: -1px;
406
485
  border-left: 1px solid var(--psp-inactive--color);
407
-
408
486
  &:hover {
409
487
  color: inherit;
410
488
  }
@@ -355,6 +355,9 @@ impl Component for ActiveColumn {
355
355
  if is_required {
356
356
  class.push("required");
357
357
  };
358
+ if !is_required {
359
+ class.push("shift-alt-icon");
360
+ }
358
361
  html! {
359
362
  <div
360
363
  class={outer_classes}
@@ -149,7 +149,8 @@ impl Component for InactiveColumn {
149
149
 
150
150
  let is_expression = ctx.props().is_expression;
151
151
 
152
- let is_active_class = ctx.props().renderer.metadata().select_mode.css();
152
+ let mut is_active_class = ctx.props().renderer.metadata().select_mode.css();
153
+ is_active_class.push("shift-alt-icon");
153
154
  let mut class = classes!("column-selector-column");
154
155
  if !ctx.props().visible {
155
156
  class.push("column-selector-column-hidden");
@@ -160,6 +160,7 @@ pub fn BoolField(props: &BoolFieldProps) -> Html {
160
160
  <div class="bool-field-container">
161
161
  <input
162
162
  type="checkbox"
163
+ class="alternate"
163
164
  id={format!("{}-checkbox", props.field_key)}
164
165
  checked={current}
165
166
  {oninput}
@@ -264,6 +264,7 @@ impl Component for ColumnSettingsPanel {
264
264
  ctx.props().selected_tab,
265
265
  Some(ColumnSettingsTab::Attributes)
266
266
  ),
267
+ update_on_input: true,
267
268
  icon_type: self
268
269
  .maybe_ty
269
270
  .map(|ty| ty.into())
@@ -193,6 +193,12 @@ where
193
193
  }
194
194
  });
195
195
 
196
+ // Held by per-row `ondragenter` closures below so they can re-arm
197
+ // the `safaridragleave` flag on the container element when the
198
+ // row stops dragenter from bubbling. See the comment inside the
199
+ // closure for why this matters.
200
+ let container_noderef = drag_container.noderef.clone();
201
+
196
202
  let invalid_drag: bool;
197
203
  let mut valid_duplicate_drag = false;
198
204
 
@@ -253,9 +259,30 @@ where
253
259
  let close = ctx.props().parent.callback(move |_| V::close(idx));
254
260
  let dragenter = ctx.props().parent.callback({
255
261
  let link = ctx.link().clone();
262
+ let container_noderef = container_noderef.clone();
256
263
  move |event: DragEvent| {
257
264
  event.stop_propagation();
258
265
  event.prevent_default();
266
+ // Safari: `relatedTarget` is always null on
267
+ // dragleave, so `dragleave_helper` uses a
268
+ // `data-safaridragleave` flag set by the
269
+ // container's own dragenter to distinguish
270
+ // child-crossing leaves (consume the flag)
271
+ // from real leaves (no flag → fire callback).
272
+ // The `stop_propagation` above blocks the
273
+ // container's dragenter, so the flag would
274
+ // never be re-armed after the first consume —
275
+ // any further internal boundary crossing
276
+ // would demote the state out of
277
+ // `DragOverInProgress` and the next drop
278
+ // would be silently rejected. Set the flag
279
+ // here so each row-targeted dragenter
280
+ // refills the pool.
281
+ if event.related_target().is_none()
282
+ && let Some(elem) = container_noderef.cast::<HtmlElement>()
283
+ {
284
+ let _ = elem.dataset().set("safaridragleave", "true");
285
+ }
259
286
  link.send_message(DragDropListMsg::Freeze(true));
260
287
  V::dragenter(idx)
261
288
  }
@@ -28,9 +28,13 @@ pub struct EditableHeaderProps {
28
28
  pub initial_value: Option<String>,
29
29
  pub placeholder: Rc<String>,
30
30
 
31
+ // TODO remove this pattern
31
32
  #[prop_or_default]
32
33
  pub reset_count: u8,
33
34
 
35
+ #[prop_or_default]
36
+ pub update_on_input: bool,
37
+
34
38
  /// Session metadata snapshot — threaded from `SessionProps`.
35
39
  pub metadata: SessionMetadataRc,
36
40
 
@@ -163,6 +167,16 @@ impl Component for EditableHeader {
163
167
  EditableHeaderMsg::SetNewValue(value)
164
168
  });
165
169
 
170
+ let update_on_input = ctx.props().update_on_input;
171
+ let oninput = ctx.link().batch_callback(move |e: yew::InputEvent| {
172
+ if update_on_input {
173
+ let value = e.target_unchecked_into::<HtmlInputElement>().value();
174
+ vec![EditableHeaderMsg::SetNewValue(value)]
175
+ } else {
176
+ vec![]
177
+ }
178
+ });
179
+
166
180
  html! {
167
181
  <div class={classes} onclick={ctx.link().callback(|_| EditableHeaderMsg::OnClick(()))}>
168
182
  if let Some(icon) = ctx.props().icon_type { <TypeIcon ty={icon} /> }
@@ -173,6 +187,7 @@ impl Component for EditableHeader {
173
187
  disabled={!ctx.props().editable}
174
188
  {onblur}
175
189
  {onkeyup}
190
+ {oninput}
176
191
  value={self.value.clone()}
177
192
  placeholder={self.placeholder.clone()}
178
193
  />
@@ -377,7 +377,7 @@ impl Component for StatusBar {
377
377
  <div id="plugin-settings"><slot name="statusbar-extra" /></div>
378
378
  <span class="hover-target">
379
379
  <span id="reset" class="button" onmousedown={&onreset}>
380
- <span class="icon" />
380
+ <span class="icon shift-alt-icon" />
381
381
  <span class="icon-label" />
382
382
  </span>
383
383
  </span>
@@ -14,7 +14,9 @@ use std::rc::Rc;
14
14
 
15
15
  use futures::channel::oneshot::*;
16
16
  use perspective_js::utils::*;
17
+ use wasm_bindgen::JsCast;
17
18
  use wasm_bindgen::prelude::*;
19
+ use web_sys::{FocusEvent, KeyboardEvent};
18
20
  use yew::prelude::*;
19
21
 
20
22
  use super::containers::split_panel::SplitPanel;
@@ -121,6 +123,70 @@ pub struct PerspectiveViewer {
121
123
  /// Counts in-flight renders (incremented on `view_config_changed`,
122
124
  /// decremented on `view_created`). Threaded to `StatusIndicator`.
123
125
  update_count: u32,
126
+
127
+ /// Window listeners that toggle the `.shift-active` class on the host
128
+ /// element while the Shift key is held, making Shift-modified affordances
129
+ /// (e.g. inactive column add, active column remove, status-bar reset)
130
+ /// visually discoverable. Stored so the closures outlive `create`.
131
+ _shift_listeners: ShiftListeners,
132
+ }
133
+
134
+ struct ShiftListeners {
135
+ elem: web_sys::HtmlElement,
136
+ keydown: Closure<dyn FnMut(KeyboardEvent)>,
137
+ keyup: Closure<dyn FnMut(KeyboardEvent)>,
138
+ blur: Closure<dyn FnMut(FocusEvent)>,
139
+ }
140
+
141
+ impl Drop for ShiftListeners {
142
+ fn drop(&mut self) {
143
+ let win = global::window();
144
+ let _ = win
145
+ .remove_event_listener_with_callback("keydown", self.keydown.as_ref().unchecked_ref());
146
+ let _ =
147
+ win.remove_event_listener_with_callback("keyup", self.keyup.as_ref().unchecked_ref());
148
+ let _ = win.remove_event_listener_with_callback("blur", self.blur.as_ref().unchecked_ref());
149
+ let _ = self.elem.class_list().remove_1("shift-active");
150
+ }
151
+ }
152
+
153
+ fn install_shift_listeners(elem: web_sys::HtmlElement) -> ShiftListeners {
154
+ let keydown = {
155
+ let elem = elem.clone();
156
+ Closure::wrap(Box::new(move |event: KeyboardEvent| {
157
+ if event.key() == "Shift" {
158
+ let _ = elem.class_list().add_1("shift-active");
159
+ }
160
+ }) as Box<dyn FnMut(KeyboardEvent)>)
161
+ };
162
+
163
+ let keyup = {
164
+ let elem = elem.clone();
165
+ Closure::wrap(Box::new(move |event: KeyboardEvent| {
166
+ if event.key() == "Shift" {
167
+ let _ = elem.class_list().remove_1("shift-active");
168
+ }
169
+ }) as Box<dyn FnMut(KeyboardEvent)>)
170
+ };
171
+
172
+ let blur = {
173
+ let elem = elem.clone();
174
+ Closure::wrap(Box::new(move |_: FocusEvent| {
175
+ let _ = elem.class_list().remove_1("shift-active");
176
+ }) as Box<dyn FnMut(FocusEvent)>)
177
+ };
178
+
179
+ let win = global::window();
180
+ let _ = win.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref());
181
+ let _ = win.add_event_listener_with_callback("keyup", keyup.as_ref().unchecked_ref());
182
+ let _ = win.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref());
183
+
184
+ ShiftListeners {
185
+ elem,
186
+ keydown,
187
+ keyup,
188
+ blur,
189
+ }
124
190
  }
125
191
 
126
192
  impl Component for PerspectiveViewer {
@@ -160,6 +226,8 @@ impl Component for PerspectiveViewer {
160
226
  });
161
227
  }
162
228
 
229
+ let shift_listeners = install_shift_listeners(elem);
230
+
163
231
  Self {
164
232
  _subscriptions: subscriptions,
165
233
  column_settings_panel_width_override: None,
@@ -178,6 +246,7 @@ impl Component for PerspectiveViewer {
178
246
  presentation_props,
179
247
  dragdrop_props: DragDropProps::default(),
180
248
  update_count: 0,
249
+ _shift_listeners: shift_listeners,
181
250
  }
182
251
  }
183
252
 
@@ -548,8 +617,15 @@ impl Component for PerspectiveViewer {
548
617
  skip_empty=true
549
618
  initial_size={self.settings_panel_width_override}
550
619
  on_reset={ctx.link().callback(|_| SettingsPanelSizeUpdate(None))}
551
- on_resize={on_split_panel_resize.clone()}
552
- on_resize_finished={render_callback(&ctx.props().session, &ctx.props().renderer)}
620
+ on_resize={{
621
+ let size_cb = on_split_panel_resize.clone();
622
+ let resize_cb = resize_callback(&ctx.props().session, &ctx.props().renderer);
623
+ move |x| {
624
+ size_cb.emit(x);
625
+ resize_cb.emit(());
626
+ }
627
+ }}
628
+ on_resize_finished={resize_callback(&ctx.props().session, &ctx.props().renderer)}
553
629
  >
554
630
  { settings_panel }
555
631
  <div id="main_column_container">
@@ -31,11 +31,10 @@ use std::ops::Deref;
31
31
  use std::pin::Pin;
32
32
  use std::rc::Rc;
33
33
 
34
- use futures::future::select_all;
34
+ use futures::future::{join_all, select_all};
35
35
  use perspective_client::config::ViewConfig;
36
36
  use perspective_client::utils::*;
37
37
  use perspective_client::{View, ViewWindow};
38
- use perspective_js::json;
39
38
  use perspective_js::utils::{ApiResult, JsValueSerdeExt, ResultTApiErrorExt};
40
39
  use serde_json::Value;
41
40
  use wasm_bindgen::prelude::*;
@@ -52,6 +51,7 @@ pub use self::registry::*;
52
51
  use self::render_timer::*;
53
52
  use crate::config::*;
54
53
  use crate::js::plugin::*;
54
+ use crate::queries::resolve_abs_max;
55
55
  use crate::session::Session;
56
56
  use crate::utils::*;
57
57
 
@@ -169,15 +169,6 @@ impl Renderer {
169
169
  }))
170
170
  }
171
171
 
172
- pub async fn reset(&self, columns_config: Option<&ColumnConfigMap>) -> ApiResult<()> {
173
- self.0.borrow_mut().plugins_idx = None;
174
- if let Ok(plugin) = self.get_active_plugin() {
175
- plugin.restore(&json!({}), columns_config)?;
176
- }
177
-
178
- Ok(())
179
- }
180
-
181
172
  pub fn delete(&self) -> ApiResult<()> {
182
173
  self.get_active_plugin().map(|x| x.delete()).unwrap_or_log();
183
174
  self.plugin_data.borrow().viewer_elem.set_inner_text("");
@@ -239,12 +230,69 @@ impl Renderer {
239
230
  /// `include` fields off other fields (e.g. Datagrid's
240
231
  /// `fg_gradient` revealed when `number_fg_mode = "bar"`) will
241
232
  /// reach the plugin without their default value populated.
242
- pub fn all_columns_configs_materialized(
233
+ ///
234
+ /// `async` because per-column stats (e.g. `abs_max` for Datagrid's
235
+ /// `fg_gradient`) may need to be fetched before the schema's
236
+ /// `default` is meaningful. Pass 1 sync-scans the schema for any
237
+ /// column whose `include: true` Number key is missing from its
238
+ /// entry AND has no cached stats — `view_config_changed` clears the
239
+ /// stats cache, so "missing in cache" subsumes "stale". Pass 2
240
+ /// blocks on a parallel `resolve_abs_max` for that set, then runs
241
+ /// the materialize loop with the now-warm cache. Columns never
242
+ /// touched in a stats-dependent mode never trigger a fetch.
243
+ pub async fn all_columns_configs_materialized(
243
244
  &self,
244
245
  view_config: &ViewConfig,
245
246
  session: &Session,
246
247
  ) -> ColumnConfigMap {
247
248
  let mut configs = self.all_columns_configs();
249
+
250
+ // Pass 1: identify columns whose schema demands an `include:
251
+ // true` Number default we don't have stats for.
252
+ let mut to_warm: Vec<String> = vec![];
253
+ for (col, entry) in &configs {
254
+ if session
255
+ .get_column_stats(col)
256
+ .and_then(|s| s.abs_max)
257
+ .is_some()
258
+ {
259
+ continue;
260
+ }
261
+ let Ok(schema) =
262
+ self.query_column_config_schema(view_config, session, col, Some(entry))
263
+ else {
264
+ continue;
265
+ };
266
+ let needs_warm = schema.fields.iter().any(|f| {
267
+ matches!(
268
+ f,
269
+ ControlSpec::Number {
270
+ key,
271
+ include: Some(true),
272
+ ..
273
+ } if !entry.contains_key(key)
274
+ )
275
+ });
276
+ if needs_warm {
277
+ to_warm.push(col.clone());
278
+ }
279
+ }
280
+
281
+ // Block on the (typically tiny) warm set. Clone the metadata
282
+ // and resolve the view ref *before* the .await — `metadata()`
283
+ // returns a live `Ref<>` guard that must not cross an await
284
+ // boundary.
285
+ if !to_warm.is_empty() {
286
+ let metadata = session.metadata().clone();
287
+ let view = session.get_view();
288
+ let futs = to_warm
289
+ .iter()
290
+ .map(|c| resolve_abs_max(session, &metadata, view.as_ref(), c.as_str()));
291
+ join_all(futs).await;
292
+ }
293
+
294
+ // Pass 2: materialize. With stats now in cache, the schema
295
+ // returns a real `default` instead of the placeholder `0`.
248
296
  for (col, entry) in &mut configs {
249
297
  let Ok(schema) =
250
298
  self.query_column_config_schema(view_config, session, col, Some(entry))
@@ -345,6 +393,8 @@ impl Renderer {
345
393
  if let Ok(schema) =
346
394
  self.query_column_config_schema(view_config, session, &col, Some(&cfg))
347
395
  {
396
+ let active = schema.active_keys();
397
+ cfg.retain(|k, _| active.contains(k));
348
398
  strip_default_values(&schema, &mut cfg);
349
399
  }
350
400
 
@@ -468,13 +518,15 @@ impl Renderer {
468
518
  /// the columns-config write paths (strip-on-write) and the
469
519
  /// restore-prep snapshot (materialize-on-read).
470
520
  ///
471
- /// Reads the cached `ColumnStats` (populated by the StyleTab's
472
- /// `fetch_column_abs_max` task and cleared on `view_config_changed`)
521
+ /// Reads the cached `ColumnStats` (cleared on `view_config_changed`)
473
522
  /// so plugins emit gradient defaults against the column's current
474
- /// `abs_max` instead of falling back to `0`. Cache cold ⇒ stats
475
- /// pass through as missing and the plugin uses its `?? 0` fallback;
476
- /// `include: true` fields are still preserved via
477
- /// [`matches_declared_default`]'s short-circuit.
523
+ /// `abs_max` instead of falling back to `0`.
524
+ /// [`Self::all_columns_configs_materialized`] warms the cache on
525
+ /// demand before materializing `include: true` Number fields, so
526
+ /// the restore path always observes a real default; sync callers
527
+ /// (column-config strip-on-write) may still see a missing stats
528
+ /// pass-through and the plugin's `?? 0` fallback, but those writes
529
+ /// re-strip on the next render cycle.
478
530
  fn query_column_config_schema(
479
531
  &self,
480
532
  view_config: &ViewConfig,
@@ -549,6 +601,8 @@ impl Renderer {
549
601
  OptionalUpdate::Update(mut map) => {
550
602
  let mut changed = false;
551
603
  if let Some(s) = &schema {
604
+ let active = s.active_keys();
605
+ map.retain(|k, _| active.contains(k));
552
606
  // Default-valued entries in a restore payload
553
607
  // semantically reset the key — strip from the
554
608
  // map AND clear any existing override in the
@@ -11,13 +11,17 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  //! Cross-engine reset orchestration: reset session config, optionally clear
14
- //! presentation columns config / theme, reset the renderer plugin state, and
15
- //! redraw.
14
+ //! presentation columns config / theme, then delegate to `restore_and_render`
15
+ //! to switch back to the default plugin and redraw.
16
16
 
17
17
  use futures::channel::oneshot;
18
18
  use perspective_client::clone;
19
19
  use perspective_js::utils::ApiFuture;
20
20
 
21
+ use super::restore_and_render;
22
+ use crate::config::{
23
+ ColumnConfigUpdate, OptionalUpdate, PluginConfigUpdate, PluginUpdate, ViewerConfigUpdate,
24
+ };
21
25
  use crate::presentation::Presentation;
22
26
  use crate::renderer::Renderer;
23
27
  use crate::session::{ResetOptions, Session};
@@ -30,6 +34,12 @@ use crate::session::{ResetOptions, Session};
30
34
  ///
31
35
  /// Optionally signals `sender` once the reset+redraw round-trip completes,
32
36
  /// then emits `renderer.reset_changed`.
37
+ ///
38
+ /// Delegates plugin selection + draw to [`restore_and_render`], whose
39
+ /// two-pass restore guarantees the default plugin sees materialized
40
+ /// `columns_config` / `plugin_config` on its first draw — fixing a race
41
+ /// where the raw post-reset bucket would reach the plugin before
42
+ /// stats-dependent `include: true` defaults were resolved.
33
43
  pub fn reset_all(
34
44
  session: &Session,
35
45
  renderer: &Renderer,
@@ -47,32 +57,42 @@ pub fn reset_all(
47
57
  ..ResetOptions::default()
48
58
  })
49
59
  .await?;
50
- let columns_config = if all {
51
- renderer.reset_columns_configs();
52
- renderer.reset_plugin_config();
53
- // Mirror the per-plugin bucket clear on the event bus so
54
- // `PluginTab` re-pulls (its props are interior-mutable
55
- // handles whose identity doesn't change on the reset).
56
- renderer
57
- .plugin_config_changed
58
- .emit(renderer.get_plugin_config());
59
- None
60
- } else {
61
- Some(renderer.all_columns_configs())
62
- };
63
60
 
64
- renderer.reset(columns_config.as_ref()).await?;
65
61
  presentation.reset_available_themes(None).await;
66
62
  if all {
67
63
  presentation.reset_theme().await?;
68
64
  }
69
65
 
70
- let result = renderer.draw(session.validate().await?.create_view()).await;
66
+ // For `all = true`, route the bucket clears through `restore_and_render`'s
67
+ // `update_*` paths as `SetDefault`. This guarantees the materialized
68
+ // restore fires even when the user is already on the default plugin
69
+ // (no plugin_swap signal), since `SetDefault` reports the bucket as
70
+ // `changed` when it was non-empty. The per-plugin bucket model means
71
+ // only the (post-swap) default plugin's bucket is cleared; other
72
+ // plugins' buckets persist with their per-plugin state.
73
+ let (columns_config, plugin_config) = if all {
74
+ (
75
+ ColumnConfigUpdate::SetDefault,
76
+ PluginConfigUpdate::SetDefault,
77
+ )
78
+ } else {
79
+ (OptionalUpdate::Missing, OptionalUpdate::Missing)
80
+ };
81
+
82
+ let update = ViewerConfigUpdate {
83
+ plugin: PluginUpdate::SetDefault,
84
+ plugin_config,
85
+ columns_config,
86
+ ..Default::default()
87
+ };
88
+
89
+ restore_and_render(&session, &renderer, &presentation, update, async { Ok(()) }).await?;
90
+
71
91
  if let Some(sender) = sender {
72
92
  sender.send(()).unwrap();
73
93
  }
74
94
 
75
95
  renderer.reset_changed.emit(());
76
- result
96
+ Ok(())
77
97
  })
78
98
  }
@@ -86,6 +86,22 @@ pub fn restore_and_render(
86
86
  let plugin_swapped = renderer.apply_pending_plugin()?;
87
87
  let plugin = renderer.get_active_plugin()?;
88
88
 
89
+ // The previous call which acquired the lock errored, so skip this render
90
+ if let Some(error) = session.get_error() {
91
+ return Err(error);
92
+ }
93
+
94
+ // Validate + create the view BEFORE applying
95
+ // columns_config / plugin_config updates, so the
96
+ // strip-on-write and materialize passes see fresh
97
+ // `expression_schema` and `view_schema`, and the
98
+ // materialize warm step can call `View::get_min_max`
99
+ // against a view that knows about any new expression
100
+ // columns. Previously this happened after the strip,
101
+ // which silently dropped any `columns_config` entry
102
+ // keyed by a new expression column.
103
+ let view = session.validate().await?.create_view().await?;
104
+
89
105
  // Apply incoming updates into the now-active plugin's
90
106
  // bucket on `Renderer`. Per-plugin storage means no
91
107
  // schema filter is needed before restore — foreign keys
@@ -96,9 +112,9 @@ pub fn restore_and_render(
96
112
  let view_config_snapshot = session.get_view_config().clone();
97
113
  let plugin_config_changed =
98
114
  renderer.update_plugin_config(&view_config_snapshot, plugin_config);
99
-
100
- let changed = plugin_config_changed
101
- || renderer.update_columns_configs(&view_config_snapshot, &session, columns_config);
115
+ let columns_config_changed =
116
+ renderer.update_columns_configs(&view_config_snapshot, &session, columns_config);
117
+ let changed = plugin_config_changed || columns_config_changed;
102
118
 
103
119
  // Force a materialized restore when the plugin just
104
120
  // swapped — `commit_plugin_idx` already restored from the
@@ -109,24 +125,19 @@ pub fn restore_and_render(
109
125
  let plugin_config_snapshot = renderer.get_plugin_config();
110
126
  let plugin_update =
111
127
  wasm_bindgen::JsValue::from_serde_ext(&plugin_config_snapshot).unwrap();
112
- let columns_config =
113
- renderer.all_columns_configs_materialized(&view_config_snapshot, &session);
128
+ let columns_config = renderer
129
+ .all_columns_configs_materialized(&view_config_snapshot, &session)
130
+ .await;
114
131
  plugin.restore(&plugin_update, Some(&columns_config))?;
115
132
  if plugin_config_changed {
116
133
  renderer.plugin_config_changed.emit(plugin_config_snapshot);
117
134
  }
118
135
  }
119
136
 
120
- // The previous call which acquired the lock errored, so skip this render
121
- if let Some(error) = session.get_error() {
122
- return Err(error);
123
- }
124
-
125
- let view = session.validate().await?.create_view().await;
126
137
  if !presentation.is_visible() {
127
138
  Ok(None)
128
139
  } else {
129
- view
140
+ Ok(view)
130
141
  }
131
142
  });
132
143
 
@@ -38,8 +38,9 @@ pub fn send_column_config(
38
38
  clone!(session, renderer);
39
39
  ApiFuture::spawn(async move {
40
40
  let view_config_snapshot = session.get_view_config().clone();
41
- let columns_configs =
42
- renderer.all_columns_configs_materialized(&view_config_snapshot, &session);
41
+ let columns_configs = renderer
42
+ .all_columns_configs_materialized(&view_config_snapshot, &session)
43
+ .await;
43
44
  let plugin_token =
44
45
  wasm_bindgen::JsValue::from_serde_ext(&renderer.get_plugin_config()).unwrap();
45
46
  renderer