@perspective-dev/viewer 5.3.1 → 5.4.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 (255) hide show
  1. package/dist/cdn/perspective-viewer.js +6 -6
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/blueprint.css +1 -1
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -1
  7. package/dist/css/gruvbox-dark.css +1 -1
  8. package/dist/css/gruvbox.css +1 -1
  9. package/dist/css/icons.css +1 -1
  10. package/dist/css/intl/ar.css +1 -0
  11. package/dist/css/intl/de.css +1 -1
  12. package/dist/css/intl/es.css +1 -1
  13. package/dist/css/intl/fr.css +1 -1
  14. package/dist/css/intl/ja.css +1 -1
  15. package/dist/css/intl/pt.css +1 -1
  16. package/dist/css/intl/zh.css +1 -1
  17. package/dist/css/intl.css +1 -1
  18. package/dist/css/ledger.css +1 -1
  19. package/dist/css/monokai.css +1 -1
  20. package/dist/css/nord.css +1 -1
  21. package/dist/css/phosphor.css +1 -1
  22. package/dist/css/pro-dark.css +1 -1
  23. package/dist/css/pro.css +1 -1
  24. package/dist/css/solarized-dark.css +1 -1
  25. package/dist/css/solarized.css +1 -1
  26. package/dist/css/themes.css +1 -1
  27. package/dist/css/vaporwave.css +1 -1
  28. package/dist/css/velvet.css +1 -1
  29. package/dist/docs/perspective-docs.json +1 -1
  30. package/dist/esm/extensions.d.ts +0 -5
  31. package/dist/esm/perspective-viewer.inline.js +9 -9
  32. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  33. package/dist/esm/perspective-viewer.js +5 -5
  34. package/dist/esm/perspective-viewer.js.map +2 -2
  35. package/dist/esm/ts-rs/ViewOnUpdateResp.d.ts +1 -1
  36. package/dist/wasm/perspective-viewer.d.ts +62 -73
  37. package/dist/wasm/perspective-viewer.js +226 -238
  38. package/dist/wasm/perspective-viewer.wasm +0 -0
  39. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -28
  40. package/package.json +7 -7
  41. package/src/css/_context_menu.css +1 -1
  42. package/src/css/_dropdown_menu.css +1 -1
  43. package/src/css/_viewer_bundle.css +7 -8
  44. package/src/css/column-dropdown.css +0 -3
  45. package/src/css/column-selector.css +8 -5
  46. package/src/css/column-settings-panel.css +0 -225
  47. package/src/css/column-style.css +13 -0
  48. package/src/css/config-selector.css +18 -10
  49. package/src/css/filter-dropdown.css +5 -5
  50. package/src/css/form/debug.css +28 -4
  51. package/src/css/function-dropdown.css +5 -5
  52. package/src/css/panel-tab.css +23 -0
  53. package/src/css/plugin-selector.css +5 -5
  54. package/src/css/render-warning.css +1 -1
  55. package/src/css/status-bar.css +52 -21
  56. package/src/css/type-icon.css +1 -3
  57. package/src/css/{containers → ui/containers}/context-menu.css +22 -16
  58. package/src/css/ui/containers/dropdown-menu-items.css +66 -0
  59. package/src/css/{containers → ui/containers}/dropdown-menu.css +8 -53
  60. package/src/css/viewer.css +32 -26
  61. package/src/rust/components/chat_panel.rs +1 -1
  62. package/src/rust/components/{form/code_editor.rs → code_editor.rs} +4 -2
  63. package/src/rust/components/column_dropdown.rs +2 -2
  64. package/src/rust/components/column_selector/active_column.rs +15 -9
  65. package/src/rust/components/column_selector/aggregate_selector.rs +1 -1
  66. package/src/rust/components/column_selector/config_selector.rs +2 -2
  67. package/src/rust/components/column_selector/filter_column.rs +8 -5
  68. package/src/rust/components/column_selector/inactive_column.rs +4 -2
  69. package/src/rust/components/column_selector/pivot_column.rs +4 -3
  70. package/src/rust/components/column_selector/sort_column.rs +7 -4
  71. package/src/rust/components/column_selector.rs +3 -3
  72. package/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs +1 -1
  73. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +15 -17
  74. package/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_selector.rs +1 -1
  75. package/src/rust/components/column_settings_sidebar/style_tab.rs +4 -5
  76. package/src/rust/components/column_settings_sidebar.rs +3 -4
  77. package/src/rust/components/copy_dropdown.rs +1 -1
  78. package/src/rust/components/datetime_column_style/custom.rs +1 -3
  79. package/src/rust/components/datetime_column_style/simple.rs +1 -2
  80. package/src/rust/components/datetime_column_style.rs +1 -2
  81. package/src/rust/components/{form/debug.rs → debug_panel.rs} +5 -5
  82. package/src/rust/components/{containers/dragdrop_list.rs → dragdrop_list.rs} +2 -10
  83. package/src/rust/components/export_dropdown.rs +1 -1
  84. package/src/rust/components/expression_editor.rs +1 -1
  85. package/src/rust/components/filter_dropdown.rs +2 -2
  86. package/src/rust/components/function_dropdown.rs +2 -2
  87. package/src/rust/components/main_panel/frame_theme.rs +3 -59
  88. package/src/rust/components/main_panel/reconcile.rs +9 -4
  89. package/src/rust/components/main_panel/render.rs +62 -11
  90. package/src/rust/components/main_panel/update.rs +39 -10
  91. package/src/rust/components/main_panel.rs +26 -2
  92. package/src/rust/components/mod.rs +5 -7
  93. package/src/rust/components/new_panel_menu.rs +97 -0
  94. package/src/rust/components/number_series_style.rs +2 -3
  95. package/src/rust/components/panel_menu.rs +61 -87
  96. package/src/rust/components/panel_tab.rs +100 -43
  97. package/src/rust/components/plugin_tab.rs +1 -1
  98. package/src/rust/components/render_warning.rs +2 -2
  99. package/src/rust/components/{status_bar_counter.rs → rows_counter.rs} +10 -10
  100. package/src/rust/components/settings_panel.rs +2 -2
  101. package/src/rust/components/status_bar.rs +113 -19
  102. package/src/rust/components/status_indicator.rs +5 -14
  103. package/src/rust/components/string_column_style.rs +1 -2
  104. package/src/rust/components/{style/mod.rs → style.rs} +32 -15
  105. package/src/rust/components/style_controls/number_string_format/digits_section.rs +3 -5
  106. package/src/rust/components/style_controls/number_string_format/misc_section.rs +1 -1
  107. package/src/rust/components/style_controls/number_string_format/style_section.rs +1 -1
  108. package/src/rust/components/viewer/msg.rs +43 -30
  109. package/src/rust/components/viewer/panels.rs +50 -19
  110. package/src/rust/components/viewer/render.rs +15 -13
  111. package/src/rust/components/viewer/settings.rs +137 -68
  112. package/src/rust/components/viewer/snapshots.rs +5 -10
  113. package/src/rust/components/viewer/wiring.rs +1 -21
  114. package/src/rust/components/viewer.rs +15 -10
  115. package/src/rust/components/window_editor.rs +8 -9
  116. package/src/rust/config/column_config_schema.rs +2 -27
  117. package/src/rust/config/mod.rs +0 -2
  118. package/src/rust/config/workspace_config.rs +2 -1
  119. package/src/rust/custom_elements/debug_plugin.rs +1 -10
  120. package/src/rust/custom_elements/mod.rs +0 -3
  121. package/src/rust/custom_elements/viewer.rs +0 -18
  122. package/src/rust/js/plugin.rs +0 -86
  123. package/src/rust/lib.rs +12 -15
  124. package/src/rust/presentation/column_locator.rs +3 -7
  125. package/src/rust/presentation.rs +68 -38
  126. package/src/rust/queries/hosted_tables.rs +38 -0
  127. package/src/rust/queries/mod.rs +2 -0
  128. package/src/rust/queries/palette_set.rs +1 -1
  129. package/src/rust/renderer/activate.rs +1 -3
  130. package/src/rust/renderer/dispatch.rs +83 -22
  131. package/src/rust/renderer/limits.rs +24 -5
  132. package/src/rust/renderer/plugin_config.rs +1 -0
  133. package/src/rust/renderer/props.rs +2 -1
  134. package/src/rust/renderer.rs +14 -27
  135. package/src/rust/session/props.rs +4 -3
  136. package/src/rust/session/view_subscription.rs +19 -6
  137. package/src/rust/session.rs +38 -70
  138. package/src/rust/tasks/create_panel.rs +1 -1
  139. package/src/rust/tasks/dismiss_render_warning.rs +1 -1
  140. package/src/rust/tasks/eject.rs +1 -0
  141. package/src/rust/tasks/pipeline.rs +0 -60
  142. package/src/rust/tasks/presize_panels.rs +24 -1
  143. package/src/rust/tasks/restore_and_render.rs +0 -17
  144. package/src/rust/tasks/send_column_config.rs +1 -1
  145. package/src/rust/tasks/send_plugin_config.rs +1 -1
  146. package/src/rust/tasks/set_edit_mode.rs +1 -1
  147. package/src/rust/{components → ui}/containers/control_group.rs +25 -3
  148. package/src/rust/{components → ui}/containers/dropdown_menu.rs +5 -2
  149. package/src/rust/{components → ui}/containers/mod.rs +0 -4
  150. package/src/rust/{components → ui}/containers/scroll_panel.rs +0 -22
  151. package/src/rust/{components → ui}/containers/scroll_panel_item.rs +0 -13
  152. package/src/rust/{components → ui}/containers/select.rs +0 -6
  153. package/src/rust/{components → ui}/containers/sidebar.rs +5 -32
  154. package/src/rust/{components → ui}/containers/split_panel.rs +8 -80
  155. package/src/rust/{components → ui}/containers/tab_list.rs +15 -9
  156. package/src/rust/{components → ui}/context_menu.rs +8 -13
  157. package/src/rust/{presentation/drag_helpers.rs → ui/dragdrop.rs} +26 -31
  158. package/src/rust/{components → ui}/font_loader.rs +0 -26
  159. package/src/rust/{components → ui}/form/color_selector.rs +4 -2
  160. package/src/rust/ui/form/intl_label.rs +81 -0
  161. package/src/rust/{components → ui}/form/mirrored_textarea.rs +0 -53
  162. package/src/rust/{components → ui}/form/mod.rs +1 -10
  163. package/src/rust/{components → ui}/form/multi_stop_gradient_selector.rs +4 -5
  164. package/src/rust/{components → ui}/form/named_value_picker.rs +1 -6
  165. package/src/rust/{components → ui}/form/number_field.rs +1 -1
  166. package/src/rust/{components → ui}/form/number_range_field.rs +1 -1
  167. package/src/rust/{components → ui}/form/optional_field.rs +3 -1
  168. package/src/rust/{components → ui}/form/palette_selector.rs +4 -5
  169. package/src/rust/{components → ui}/form/select_enum_field.rs +2 -2
  170. package/src/rust/{components → ui}/form/select_value_field.rs +2 -2
  171. package/src/rust/ui/mod.rs +56 -0
  172. package/src/rust/ui/modal.rs +61 -0
  173. package/src/rust/{components → ui}/portal.rs +174 -79
  174. package/src/rust/{components/style/style_provider.rs → ui/style.rs} +4 -34
  175. package/src/rust/utils/completion.rs +0 -6
  176. package/src/rust/{config/css_scale.rs → utils/css.rs} +38 -35
  177. package/src/rust/utils/mod.rs +2 -0
  178. package/src/rust/workspace.rs +35 -0
  179. package/src/svg/boolean-type.svg +3 -2
  180. package/src/svg/checkbox-checked-icon.svg +4 -7
  181. package/src/svg/checkbox-hover.svg +3 -3
  182. package/src/svg/checkbox-off.svg +2 -3
  183. package/src/svg/checkbox-on.svg +3 -3
  184. package/src/svg/checkbox-unchecked-icon.svg +3 -3
  185. package/src/svg/datagrid-select-column.svg +8 -15
  186. package/src/svg/datagrid-select-region.svg +8 -15
  187. package/src/svg/datagrid-select-row-tree.svg +7 -12
  188. package/src/svg/datagrid-select-row.svg +8 -13
  189. package/src/svg/date-type.svg +4 -23
  190. package/src/svg/drawer-tab-hover.svg +4 -6
  191. package/src/svg/drawer-tab-invert-hover.svg +5 -5
  192. package/src/svg/drawer-tab-invert.svg +3 -5
  193. package/src/svg/drawer-tab.svg +5 -5
  194. package/src/svg/mega-menu-icons-candlestick.svg +5 -37
  195. package/src/svg/mega-menu-icons-ohlc.svg +4 -39
  196. package/src/svg/mega-menu-icons-sunburst.svg +4 -38
  197. package/src/svg/mega-menu-icons-treemap.svg +3 -43
  198. package/src/svg/mega-menu-icons-y-scatter.svg +12 -72
  199. package/src/svg/new-panel-icon.svg +4 -0
  200. package/src/svg/number-type.svg +3 -3
  201. package/src/svg/radio-hover.svg +3 -3
  202. package/src/svg/radio-off.svg +3 -3
  203. package/src/svg/radio-on.svg +3 -3
  204. package/src/svg/status_ok.svg +3 -26
  205. package/src/svg/string-type.svg +4 -8
  206. package/src/svg/theme-icon.svg +5 -5
  207. package/src/themes/blueprint.css +0 -57
  208. package/src/themes/botanical.css +0 -36
  209. package/src/themes/defaults.css +1 -24
  210. package/src/themes/dracula.css +0 -58
  211. package/src/themes/eggplant.css +0 -58
  212. package/src/themes/gruvbox-dark.css +0 -65
  213. package/src/themes/gruvbox.css +1 -39
  214. package/src/themes/icons.css +1 -8
  215. package/src/themes/intl/ar.css +185 -0
  216. package/src/themes/intl/de.css +45 -35
  217. package/src/themes/intl/es.css +40 -30
  218. package/src/themes/intl/fr.css +39 -29
  219. package/src/themes/intl/ja.css +41 -31
  220. package/src/themes/intl/pt.css +37 -27
  221. package/src/themes/intl/zh.css +47 -37
  222. package/src/themes/intl.css +16 -3
  223. package/src/themes/ledger.css +1 -59
  224. package/src/themes/monokai.css +0 -51
  225. package/src/themes/nord.css +0 -55
  226. package/src/themes/phosphor.css +0 -48
  227. package/src/themes/pro-dark.css +0 -37
  228. package/src/themes/pro.css +3 -23
  229. package/src/themes/solarized-dark.css +0 -31
  230. package/src/themes/solarized.css +1 -24
  231. package/src/themes/vaporwave.css +0 -44
  232. package/src/themes/velvet.css +0 -60
  233. package/src/ts/extensions.ts +0 -13
  234. package/src/ts/ts-rs/ViewOnUpdateResp.ts +1 -1
  235. package/src/css/plugin-settings-panel.css +0 -171
  236. package/src/rust/components/modal.rs +0 -177
  237. package/src/rust/custom_elements/copy_dropdown.rs +0 -219
  238. package/src/rust/custom_elements/export_dropdown.rs +0 -216
  239. package/src/rust/custom_elements/modal.rs +0 -299
  240. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-adc6f3f79141112b}/inline0.js +0 -0
  241. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-adc6f3f79141112b}/inline1.js +0 -0
  242. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-adc6f3f79141112b}/inline2.js +0 -0
  243. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-adc6f3f79141112b}/inline3.js +0 -0
  244. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-adc6f3f79141112b}/inline4.js +0 -0
  245. /package/dist/wasm/snippets/{perspective-viewer-87bd21b215d0631a → perspective-viewer-adc6f3f79141112b}/inline5.js +0 -0
  246. /package/src/css/{containers → ui/containers}/scroll-panel.css +0 -0
  247. /package/src/css/{containers → ui/containers}/split-panel.css +0 -0
  248. /package/src/css/{containers → ui/containers}/tabs.css +0 -0
  249. /package/src/css/{dom → ui/dom}/checkbox.css +0 -0
  250. /package/src/css/{dom → ui/dom}/scrollbar.css +0 -0
  251. /package/src/css/{dom → ui/dom}/select.css +0 -0
  252. /package/src/css/{form → ui/form}/mirrored-textarea.css +0 -0
  253. /package/src/rust/components/{form → code_editor}/highlight.rs +0 -0
  254. /package/src/rust/{components → ui}/containers/sidebar_close_button.rs +0 -0
  255. /package/src/rust/{components → ui}/form/number_input.rs +0 -0
@@ -19,6 +19,7 @@ use std::future::Future;
19
19
  use std::rc::Rc;
20
20
 
21
21
  use perspective_client::View;
22
+ use perspective_client::proto::ViewDimensionsResp;
22
23
  use perspective_js::utils::{ApiResult, ResultTApiErrorExt};
23
24
  use wasm_bindgen::prelude::*;
24
25
  use web_sys::*;
@@ -44,25 +45,27 @@ impl Renderer {
44
45
  let _ = el.class_list().toggle_with_force("multi", !is_solo);
45
46
  }
46
47
 
47
- /// Stamp this panel's effective `theme` attribute.
48
48
  pub fn stamp_theme(&self, plugin: Option<&JsPerspectiveViewerPlugin>) {
49
+ let theme = self.theme();
49
50
  let active_plugin = self.active_plugin();
50
51
  if let Some(plugin) = plugin.or(active_plugin.as_ref()) {
51
- let theme_elem = plugin.unchecked_ref::<HtmlElement>();
52
- match self.theme() {
53
- Some(theme)
54
- if theme_elem.get_attribute("theme").as_deref() != Some(theme.as_str()) =>
55
- {
56
- let _ = theme_elem.set_attribute("theme", &theme);
57
- },
58
- Some(_) => {},
59
- None => {
60
- let _ = theme_elem.remove_attribute("theme");
61
- },
62
- }
52
+ stamp_theme_attr(plugin.unchecked_ref::<Element>(), theme.as_deref());
53
+ }
54
+
55
+ if let Some(tab) = self.tab_host() {
56
+ stamp_theme_attr(&tab, theme.as_deref());
63
57
  }
64
58
  }
65
59
 
60
+ fn tab_host(&self) -> Option<Element> {
61
+ let slot = format!("tab-{}", self.slot_name()?);
62
+ let viewer = self.plugin_data.borrow().viewer_elem.clone();
63
+ let children = viewer.children();
64
+ (0..children.length())
65
+ .filter_map(|i| children.item(i))
66
+ .find(|el| el.get_attribute("slot").as_deref() == Some(slot.as_str()))
67
+ }
68
+
66
69
  /// Restyle the active plugin and re-draw.
67
70
  pub async fn restyle_all(&self) -> ApiResult<JsValue> {
68
71
  self.render_task(|guard| async move {
@@ -78,8 +81,14 @@ impl Renderer {
78
81
  self.stamp_active(&plugin);
79
82
  plugin.restyle();
80
83
  *self.0.captured_theme.borrow_mut() = Some(stamped_theme);
81
- let mut limits =
82
- get_row_and_col_limits(&ctx.view, &meta, self.is_render_warning_enabled()).await?;
84
+ let dimensions = ctx.view.dimensions().await?;
85
+ let mut limits = get_row_and_col_limits(
86
+ &dimensions,
87
+ &ctx.view,
88
+ &meta,
89
+ self.is_render_warning_enabled(),
90
+ )
91
+ .await?;
83
92
 
84
93
  limits.is_update = true;
85
94
  plugin
@@ -215,6 +224,7 @@ impl Renderer {
215
224
  };
216
225
 
217
226
  self.stamp_active(&jsplugin);
227
+ self.0.presized_box.set(None);
218
228
  jsplugin.resize().await?;
219
229
  Ok(None)
220
230
  },
@@ -241,7 +251,33 @@ impl Renderer {
241
251
  self.stamp_active(&plugin);
242
252
  self.clear_presize()?;
243
253
  if plugin.capabilities().presize {
244
- return plugin.presize(width, height).await;
254
+ let Some(present) = plugin.presize(width, height).await? else {
255
+ return Ok(None);
256
+ };
257
+
258
+ self.0.presized_box.set(Some((width, height)));
259
+ let inline = js_sys::Reflect::get(&present, &"inline".into())
260
+ .ok()
261
+ .and_then(|v| v.as_bool())
262
+ .unwrap_or(false);
263
+
264
+ if inline {
265
+ self.0.presized_box.set(None);
266
+ }
267
+
268
+ let renderer = self.clone();
269
+ let wrapped = Closure::once_into_js(move || {
270
+ let filled = present
271
+ .call0(&JsValue::NULL)
272
+ .map(|v| v.as_bool() != Some(false))
273
+ .unwrap_or(false);
274
+
275
+ if !filled {
276
+ renderer.0.presized_box.set(None);
277
+ }
278
+ });
279
+
280
+ return Ok(Some(wrapped.unchecked_into::<js_sys::Function>()));
245
281
  }
246
282
 
247
283
  let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
@@ -278,6 +314,7 @@ impl Renderer {
278
314
  }
279
315
 
280
316
  result?;
317
+ self.0.presized_box.set(Some((width, height)));
281
318
  }
282
319
 
283
320
  Ok(None)
@@ -307,14 +344,16 @@ impl Renderer {
307
344
  pub async fn draw_fresh(&self, guard: &RenderGuard, view: FreshView) -> ApiResult<()> {
308
345
  let timer = self.render_timer();
309
346
  timer
310
- .capture_time(self.draw_view(guard, view.view(), false))
347
+ .capture_time(self.draw_view(guard, view.view(), false, None))
311
348
  .await
312
349
  }
313
350
 
314
351
  /// Repaint the ALREADY-BOUND `view` on the active plugin.
315
352
  pub async fn update_bound(&self, guard: &RenderGuard, view: &View) -> ApiResult<()> {
316
353
  let timer = self.render_timer();
317
- timer.capture_time(self.draw_view(guard, view, true)).await
354
+ timer
355
+ .capture_time(self.draw_view(guard, view, true, None))
356
+ .await
318
357
  }
319
358
 
320
359
  /// Mint the [`FreshView`] full-draw witness for a plugin that has never
@@ -363,7 +402,7 @@ impl Renderer {
363
402
  /// Redraw an already-bound view, debounced.
364
403
  pub async fn update_lazy(
365
404
  &self,
366
- view: impl Future<Output = ApiResult<Option<View>>>,
405
+ view: impl Future<Output = ApiResult<Option<(View, Option<ViewDimensionsResp>)>>>,
367
406
  ) -> ApiResult<()> {
368
407
  let timer = self.render_timer();
369
408
  self.draw_lock()
@@ -382,14 +421,14 @@ impl Renderer {
382
421
  }
383
422
 
384
423
  self.mount_active_plugin()?;
385
- if let Some(view) = view.await? {
424
+ if let Some((view, dimensions)) = view.await? {
386
425
  let _pin = self
387
426
  .cached_context()
388
427
  .map(|ctx| self.pin_context(&guard, ctx));
389
428
 
390
429
  let timer = self.render_timer();
391
430
  timer
392
- .capture_time(self.draw_view(&guard, &view, true))
431
+ .capture_time(self.draw_view(&guard, &view, true, dimensions))
393
432
  .await
394
433
  } else {
395
434
  tracing::debug!("Render skipped, no `View` attached");
@@ -399,11 +438,14 @@ impl Renderer {
399
438
  .await
400
439
  }
401
440
 
441
+ /// `dimensions` is the caller's already-fetched view dimensions, or
442
+ /// `None` to fetch here.
402
443
  async fn draw_view(
403
444
  &self,
404
445
  _guard: &RenderGuard,
405
446
  view: &perspective_client::View,
406
447
  is_update: bool,
448
+ dimensions: Option<ViewDimensionsResp>,
407
449
  ) -> ApiResult<()> {
408
450
  debug_assert!(
409
451
  !is_update || self.cached_context().is_none() || self.render_context().is_some(),
@@ -420,8 +462,14 @@ impl Renderer {
420
462
  };
421
463
 
422
464
  let meta = self.metadata();
465
+ let dimensions = match dimensions {
466
+ Some(dimensions) => dimensions,
467
+ None => view.dimensions().await?,
468
+ };
469
+
423
470
  let mut limits =
424
- get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
471
+ get_row_and_col_limits(&dimensions, view, &meta, self.is_render_warning_enabled())
472
+ .await?;
425
473
 
426
474
  limits.is_update = is_update;
427
475
  if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() {
@@ -440,6 +488,7 @@ impl Renderer {
440
488
  }
441
489
 
442
490
  self.0.data_stale.set(false);
491
+ self.0.presized_box.set(None);
443
492
  let result = if is_update {
444
493
  let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false);
445
494
  activate_plugin(_guard, &viewer_elem, &plugin, slot.as_deref(), task).await
@@ -499,3 +548,15 @@ impl Drop for PresizePendingGuard {
499
548
  cell.set(cell.get() - 1);
500
549
  }
501
550
  }
551
+
552
+ fn stamp_theme_attr(el: &Element, theme: Option<&str>) {
553
+ match theme {
554
+ Some(theme) if el.get_attribute("theme").as_deref() != Some(theme) => {
555
+ let _ = el.set_attribute("theme", theme);
556
+ },
557
+ Some(_) => {},
558
+ None => {
559
+ let _ = el.remove_attribute("theme");
560
+ },
561
+ }
562
+ }
@@ -10,6 +10,7 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
+ use perspective_client::proto::ViewDimensionsResp;
13
14
  use perspective_js::utils::ApiError;
14
15
 
15
16
  use crate::config::PluginStaticConfig;
@@ -31,16 +32,34 @@ pub struct RenderLimits {
31
32
  pub max_rows: Option<usize>,
32
33
  }
33
34
 
34
- /// Compute the row/column caps for `view` against the plugin's static
35
- /// limits. `render_warning` is the renderer-state flag controlling
36
- /// whether the warning is currently armed; when `false`, an oversized
37
- /// view renders uncapped (the user has dismissed the warning).
35
+ impl RenderLimits {
36
+ /// The column cap is in effect (fewer columns render than the view has).
37
+ pub fn is_col_capped(&self) -> bool {
38
+ self.max_cols.is_some_and(|x| x < self.num_cols)
39
+ }
40
+
41
+ /// The row cap is in effect (fewer rows render than the view has).
42
+ pub fn is_row_capped(&self) -> bool {
43
+ self.max_rows.is_some_and(|x| x < self.num_rows)
44
+ }
45
+
46
+ /// Either cap is in effect — exactly when the render warning shows.
47
+ pub fn is_capped(&self) -> bool {
48
+ self.is_col_capped() || self.is_row_capped()
49
+ }
50
+ }
51
+
52
+ /// Compute the row/column caps for `view` from its already-fetched
53
+ /// `dimensions` against the plugin's static limits. `render_warning` is the
54
+ /// renderer-state flag controlling whether the warning is currently armed;
55
+ /// when `false`, an oversized view renders uncapped (the user has dismissed
56
+ /// the warning).
38
57
  pub async fn get_row_and_col_limits(
58
+ dimensions: &ViewDimensionsResp,
39
59
  view: &perspective_client::View,
40
60
  config: &PluginStaticConfig,
41
61
  render_warning: bool,
42
62
  ) -> Result<RenderLimits, ApiError> {
43
- let dimensions = view.dimensions().await?;
44
63
  let num_cols = dimensions.num_view_columns as usize;
45
64
  let num_rows = dimensions.num_view_rows as usize;
46
65
  match (config.max_columns, render_warning) {
@@ -26,6 +26,7 @@ use super::Renderer;
26
26
  use crate::config::*;
27
27
  use crate::queries::resolve_abs_max;
28
28
  use crate::session::Session;
29
+ use crate::utils::{CssKind, CssLiteralUse, parse_var_ref, resolve_css_refs};
29
30
 
30
31
  /// A per-column config map. Each inner [`serde_json::Map`] is a flat collection
31
32
  /// of plugin-defined JSON keys whose shape is dictated by the active plugin's
@@ -30,7 +30,8 @@ pub struct RendererProps {
30
30
  /// internal `metadata` — `Rc::ptr_eq` works.
31
31
  pub config: Rc<PluginStaticConfig>,
32
32
 
33
- /// Most recently emitted render limits, if any.
33
+ /// The most recently emitted render limits, only while a cap is in
34
+ /// effect.
34
35
  pub render_limits: Option<RenderLimits>,
35
36
 
36
37
  /// Names of all registered plugins, in registration order.
@@ -120,6 +120,10 @@ pub struct RendererData {
120
120
  /// [`Renderer::presize_with_box`] presize calls.
121
121
  presize_pending: Cell<u32>,
122
122
 
123
+ /// The `(width, height)` the plugin last painted at through a presize,
124
+ /// consumed by the post-commit reactive resize.
125
+ presized_box: Cell<Option<(f64, f64)>>,
126
+
123
127
  /// Debounce slot for geometry tasks (`resize` / presize), separate from
124
128
  /// the default slot data-update redraws coalesce on. Invariant: a
125
129
  /// geometry request must never resolve via a parked DATA task — a
@@ -160,14 +164,7 @@ pub struct RendererData {
160
164
 
161
165
  /// The effective theme stamped at the active plugin's last `--psp-*`
162
166
  /// CSS CAPTURE — its first paint ([`Renderer::draw_view`]) or last
163
- /// [`Renderer::restyle_all`]. `None` = no capture has happened. The
164
- /// state behind [`Renderer::needs_restyle`]: restyle necessity is a
165
- /// comparison against what the plugin actually captured, never
166
- /// inferred from which call site mutated a theme record (the
167
- /// raycasting boot double render — see
168
- /// `PLUGIN_DRAW_INVARIANT_PLAN.md`, captured-theme revision). Same
169
- /// lifecycle as [`Self::has_drawn`]: cleared on plugin swap,
170
- /// `dispose` and `delete`; written only inside locked dispatches.
167
+ /// [`Renderer::restyle_all`].
171
168
  captured_theme: RefCell<Option<Option<String>>>,
172
169
 
173
170
  /// The [`RenderContext`] of the currently-bound `View` (built at bind
@@ -268,6 +265,7 @@ impl Renderer {
268
265
  plugin_config_changed: Default::default(),
269
266
  render_warning: Cell::new(true),
270
267
  presize_pending: Cell::new(0),
268
+ presized_box: Cell::new(None),
271
269
  on_render_limits_changed: Default::default(),
272
270
  slot_name: Default::default(),
273
271
  theme: Default::default(),
@@ -315,14 +313,7 @@ impl Renderer {
315
313
 
316
314
  /// Whether the active plugin's captured `--psp-*` CSS is STALE — the
317
315
  /// effective theme differs from the one stamped at the plugin's last
318
- /// CSS capture (first paint / last restyle). The state-keyed gate for
319
- /// every theme-driven `restyle_all` (the `has_drawn` analog for
320
- /// `restyle` — `PLUGIN_DRAW_INVARIANT_PLAN.md`, captured-theme
321
- /// revision): a caller that just mutated a theme RECORD may not need a
322
- /// restyle at all if the plugin's capture already reflects the new
323
- /// value (e.g. the same restore also performed the first paint,
324
- /// post-stamp). `false` when no capture exists yet — the owed first
325
- /// paint captures fresh by construction ("stamp before draw").
316
+ /// CSS capture (first paint / last restyle).
326
317
  pub fn needs_restyle(&self) -> bool {
327
318
  match &*self.0.captured_theme.borrow() {
328
319
  Some(captured) => *captured != self.theme(),
@@ -336,17 +327,6 @@ impl Renderer {
336
327
  /// [`Self::delete`], which clears the entire light DOM). Removes the
337
328
  /// plugin (`slot=<id>`) and any panel-scoped aux element it mounted
338
329
  /// (e.g. the datagrid toolbar, `slot=statusbar-extra-<id>`).
339
- ///
340
- /// The teardown is DEFERRED through this renderer's draw lock: spawned
341
- /// draws are uncancellable, so a synchronous `plugin.delete()` here could
342
- /// land mid-`plugin.draw()`, which the serialized-call contract forbids
343
- /// (see [`crate::js::plugin`]). Deferral is invisible — the caller
344
- /// (`eject_panel`) has already removed the panel from the `Workspace`, so
345
- /// the same commit unmounts its frame and the plugin element sits
346
- /// unslotted (unrendered) until teardown lands; panel ids are never
347
- /// reused, so it can't collide with a replacement panel's slot. Draws
348
- /// still queued behind the lock no-op via the synchronous session reset
349
- /// (`get_view()` → `None`).
350
330
  pub fn dispose(&self) -> ApiFuture<()> {
351
331
  self.0.has_drawn.set(false);
352
332
  self.0.captured_theme.borrow_mut().take();
@@ -488,6 +468,12 @@ impl Renderer {
488
468
  Ok(self.0.has_drawn.get())
489
469
  }
490
470
 
471
+ /// Take the box the plugin last painted at through a presize (see
472
+ /// `RendererData::presized_box`).
473
+ pub fn take_presized_box(&self) -> Option<(f64, f64)> {
474
+ self.0.presized_box.take()
475
+ }
476
+
491
477
  /// Mount the selected plugin element into the viewer's light DOM without
492
478
  /// drawing it (see [`activate::mount_plugin`] — idempotent). Pure query:
493
479
  /// a renderer with no selection is a no-op, deferring to the draw path's
@@ -695,6 +681,7 @@ impl Renderer {
695
681
  /// suitable for passing as a Yew prop. Called by the root component
696
682
  /// whenever a renderer-related PubSub event fires.
697
683
  pub fn to_props(&self, render_limits: Option<RenderLimits>) -> RendererProps {
684
+ let render_limits = render_limits.filter(RenderLimits::is_capped);
698
685
  let has_plugin = self.active_plugin().is_some();
699
686
  if has_plugin {
700
687
  let config = self.metadata();
@@ -19,7 +19,7 @@ use crate::session::column_defaults_update::ViewConfigUpdateExt;
19
19
  use crate::session::drag_drop_update::ViewConfigExt as DragDropExt;
20
20
  use crate::session::metadata::SessionMetadataRc;
21
21
  use crate::session::replace_expression_update::ViewConfigExt as ReplaceExprExt;
22
- use crate::session::{ColumnStats, TableErrorState, ViewStats};
22
+ use crate::session::{ColumnStats, TableErrorState};
23
23
  use crate::utils::*;
24
24
 
25
25
  #[derive(Clone, Debug, PartialEq, Default)]
@@ -39,8 +39,9 @@ pub struct SessionProps {
39
39
  /// The current `ViewConfig` driving the active `View`.
40
40
  pub config: PtrEqRc<ViewConfig>,
41
41
 
42
- /// Row/column statistics for the status bar.
43
- pub stats: Option<ViewStats>,
42
+ /// Whether the view stats carry table dimensions yet, the status
43
+ /// indicator's loaded/uninitialized split.
44
+ pub has_table_cells: bool,
44
45
 
45
46
  /// `true` if a `Table` has been loaded into this session.
46
47
  pub has_table: Option<TableLoadState>,
@@ -10,10 +10,11 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- use std::cell::Cell;
13
+ use std::cell::{Cell, RefCell};
14
14
  use std::rc::Rc;
15
15
 
16
16
  use perspective_client::config::*;
17
+ use perspective_client::proto::ViewDimensionsResp;
17
18
  use perspective_client::{OnUpdateOptions, View};
18
19
  use wasm_bindgen::prelude::*;
19
20
  use yew::prelude::*;
@@ -41,6 +42,10 @@ struct ViewSubscriptionData {
41
42
  on_stats: Callback<ViewStats>,
42
43
  on_update: Option<Callback<()>>,
43
44
  is_deleted: Rc<Cell<bool>>,
45
+
46
+ /// The bound `View`'s latest known dimensions, written before
47
+ /// `on_update`/`on_stats` fire.
48
+ dimensions: Rc<RefCell<Option<ViewDimensionsResp>>>,
44
49
  }
45
50
 
46
51
  /// A subscription to `on_update()` events from a Perspective `View()`, managing
@@ -52,20 +57,21 @@ pub struct ViewSubscription {
52
57
  impl ViewSubscriptionData {
53
58
  /// Main handler when underlying `View()` calls `on_update()`.
54
59
  async fn on_view_update(self) -> ApiResult<JsValue> {
60
+ self.clone().update_view_stats().await?;
55
61
  if let Some(on_update) = &self.on_update {
56
62
  on_update.emit(());
57
63
  };
58
64
 
59
- self.clone().update_view_stats().await?;
60
65
  Ok(JsValue::UNDEFINED)
61
66
  }
62
67
 
63
68
  async fn update_view_stats(self) -> ApiResult<JsValue> {
64
69
  let dimensions = self.view.dimensions().await?;
65
- let num_rows = dimensions.num_table_rows as u32;
66
- let num_cols = dimensions.num_table_columns as u32;
67
- let virtual_rows = dimensions.num_view_rows as u32;
68
- let virtual_cols = dimensions.num_view_columns as u32;
70
+ let num_rows = dimensions.num_table_rows;
71
+ let num_cols = dimensions.num_table_columns;
72
+ let virtual_rows = dimensions.num_view_rows;
73
+ let virtual_cols = dimensions.num_view_columns;
74
+ *self.dimensions.borrow_mut() = Some(dimensions);
69
75
  let stats = ViewStats {
70
76
  num_table_cells: Some((num_rows, num_cols)),
71
77
  num_view_cells: Some((virtual_rows, virtual_cols)),
@@ -119,6 +125,7 @@ impl ViewSubscription {
119
125
  callback_id: Rc::default(),
120
126
  on_update,
121
127
  is_deleted: Rc::default(),
128
+ dimensions: Rc::default(),
122
129
  };
123
130
 
124
131
  if data.on_update.is_some() {
@@ -160,6 +167,12 @@ impl ViewSubscription {
160
167
  &self.data.view
161
168
  }
162
169
 
170
+ /// The bound `View`'s latest known dimensions (`None` until the first
171
+ /// fetch resolves).
172
+ pub fn dimensions(&self) -> Option<ViewDimensionsResp> {
173
+ self.data.dimensions.borrow().clone()
174
+ }
175
+
163
176
  /// User-facing snapshot of the [`ViewConfig`] the bound `View` was
164
177
  /// constructed from (the persisted config at build time — global-filter
165
178
  /// overlay excluded). This is the [`ViewConfig`] consistent with the
@@ -24,6 +24,7 @@ use std::ops::Deref;
24
24
  use std::rc::Rc;
25
25
 
26
26
  use perspective_client::config::*;
27
+ use perspective_client::proto::ViewDimensionsResp;
27
28
  use perspective_client::{Client, ClientError, ReconnectCallback, View};
28
29
  use perspective_js::apierror;
29
30
  use perspective_js::utils::*;
@@ -56,11 +57,7 @@ pub struct SessionHandle {
56
57
  pub table_loaded: PubSub<()>,
57
58
  pub table_unloaded: PubSub<bool>,
58
59
 
59
- /// Fires when a `View` was CREATED — literally: `bind_view`'s REBUILD
60
- /// path constructed and bound a new engine `View`. Nothing else may
61
- /// emit this (a SKIP/REUSE/paused reconcile is [`Self::commit_reconciled`],
62
- /// which REBUILD also emits) — overloading it broke consumers that
63
- /// relied on the name's meaning (see `UPDATE_COUNT_REGRESSION_PLAN.md`).
60
+ /// Fires when a `View` was CREATED — literally.
64
61
  pub view_created: PubSub<()>,
65
62
 
66
63
  /// Fires exactly once per locked run that RECONCILED the committed
@@ -74,43 +71,23 @@ pub struct SessionHandle {
74
71
  pub view_config_changed: PubSub<()>,
75
72
  pub title_changed: PubSub<Option<String>>,
76
73
 
77
- /// Count of in-flight CONFIG-DRIVEN pipeline runs (commit → run
78
- /// settled), incremented by [`Session::begin_config_run`] and
79
- /// decremented when the returned [`ConfigRunToken`] drops. Drives the
80
- /// `StatusIndicator` "updating" spinner via [`Self::run_state_changed`].
81
- /// Pure redraws (resize, activation nudges, `just_render`) do NOT
82
- /// count — the spinner means "a config requery is in flight".
74
+ /// Count of in-flight CONFIG-DRIVEN pipeline runs.
83
75
  in_flight_config_runs: Cell<u32>,
84
76
 
85
77
  /// Fires with the ABSOLUTE [`Self::in_flight_config_runs`] count after
86
- /// every change. LEVEL-triggered by design: subscribers ASSIGN the
87
- /// payload rather than accumulate deltas, so a dropped or reordered
88
- /// notification is corrected by the next one — the delta-counting
89
- /// (`view_config_changed`+1 / `view_created`−1) design this replaces
90
- /// drifted whenever those streams weren't perfectly paired (see
91
- /// `UPDATE_COUNT_REGRESSION_PLAN.md`).
78
+ /// every change.
92
79
  pub run_state_changed: PubSub<u32>,
93
80
 
94
81
  /// Fires when the user clicks the status indicator while in
95
- /// [`StatusIconState::Normal`]. `wire_panel_events` is the only
96
- /// listener and fans this out as the `perspective-status-indicator-click`
97
- /// `CustomEvent`.
82
+ /// [`StatusIconState::Normal`].
98
83
  pub status_indicator_clicked: PubSub<()>,
99
84
 
100
- /// Per-column numeric stats cache. Populated by the
101
- /// `fetch_column_abs_max` task and consumed by the schema-query
102
- /// path so plugins emit gradient defaults without
103
- /// per-render `View::get_min_max` round trips. Cleared
104
- /// synchronously inside [`Session::commit_view_config`].
85
+ /// Per-column numeric stats cache.
105
86
  column_stats: RefCell<HashMap<String, ColumnStats>>,
106
87
 
107
88
  /// Memoized snapshots used by [`Session::to_props`] to keep
108
89
  /// `PtrEqRc` identity stable across repeated `to_props()` calls
109
- /// when the underlying value hasn't changed. Without this, every
110
- /// `PubSub` fire (including `column_stats_changed`) produces a
111
- /// fresh `Rc` for `config` / `metadata`, triggering downstream
112
- /// `use_effect_with(view_config, ...)` effects to spuriously
113
- /// refire — closing a loop with the stats fetch path.
90
+ /// when the underlying value hasn't changed.
114
91
  cached_config: RefCell<Option<PtrEqRc<ViewConfig>>>,
115
92
  cached_metadata: RefCell<Option<SessionMetadataRc>>,
116
93
 
@@ -131,18 +108,7 @@ pub struct SessionHandle {
131
108
  load_generation: Cell<u32>,
132
109
 
133
110
  /// Open between a `load()` call and its payload's classification as
134
- /// `Table`/`Client` ([`PendingLoad`]). While `Some` the payload type is
135
- /// unknown, so its RESET/no-RESET disposition cannot be decided yet — but
136
- /// the window's POSITION on the config-commit stream is fixed at the
137
- /// `load()` call site. During the window, config commits still apply live
138
- /// (so `save()` stays coherent — I1) but ALSO append their raw delta to
139
- /// the journal, and config-driven binds DEFER ([`Session::snapshot`]'s
140
- /// caller [`crate::tasks::bind_snapshot`]) — the incoming config must
141
- /// never draw against the still-bound outgoing table (hold the last
142
- /// frame). Classified as `Table` the journal replays over a reset base
143
- /// (`reset ∘ Δ₁ ∘ Δ₂ …`, the program-order result the old synchronous
144
- /// reset guaranteed); as `Client` it is dropped (no reset — live already
145
- /// carries every Δ). See `SESSION_CONFIG_COHERENCE_PLAN.md`.
111
+ /// `Table`/`Client` ([`PendingLoad`]).
146
112
  pending_load: RefCell<Option<PendingLoad>>,
147
113
 
148
114
  /// Coalesces `view_config_changed`: multiple synchronous commits in one
@@ -165,8 +131,11 @@ pub struct SessionHandle {
165
131
  /// new value.
166
132
  pub column_stats_changed: PubSub<()>,
167
133
 
168
- /// Injected callback from the root component, replacing the former
169
- /// `stats_changed: PubSub` field. Fires when view stats are updated.
134
+ /// Fires when view stats are updated.
135
+ pub stats_changed: PubSub<()>,
136
+
137
+ /// Injected callback from the root component, driving
138
+ /// `session_props.has_table_cells` for the active panel.
170
139
  pub on_stats_changed: RefCell<Option<Callback<()>>>,
171
140
 
172
141
  /// Injected callback from the root component, replacing the former
@@ -279,13 +248,7 @@ pub struct ResetOptions {
279
248
  #[derive(Clone)]
280
249
  pub struct Session(Rc<SessionHandle>);
281
250
 
282
- /// RAII spinner accounting for one config-driven pipeline run (see
283
- /// [`Session::begin_config_run`] and `UPDATE_COUNT_REGRESSION_PLAN.md`):
284
- /// created when the run is scheduled — immediately after its commit — and
285
- /// moved INTO the run future, so `Drop` settles the count on every exit
286
- /// path: completion, error, cancellation, and runs that never reach
287
- /// `bind_view` (the deferred-draw restore). A stranded count is
288
- /// unrepresentable; there is no other writer.
251
+ /// RAII spinner accounting for one config-driven pipeline run.
289
252
  pub struct ConfigRunToken(Session);
290
253
 
291
254
  impl Drop for ConfigRunToken {
@@ -664,10 +627,27 @@ impl Session {
664
627
  .map(|sub| sub.get_view().clone())
665
628
  }
666
629
 
630
+ /// The bound `View` together with its latest known dimensions, read under
631
+ /// one borrow so they always belong together.
632
+ pub fn get_view_with_dimensions(&self) -> Option<(View, Option<ViewDimensionsResp>)> {
633
+ self.borrow()
634
+ .view_sub
635
+ .as_ref()
636
+ .map(|sub| (sub.get_view().clone(), sub.dimensions()))
637
+ }
638
+
667
639
  pub(crate) fn get_table_stats(&self) -> Option<ViewStats> {
668
640
  self.borrow().stats.clone()
669
641
  }
670
642
 
643
+ /// Whether the stats snapshot carries table dimensions.
644
+ pub(crate) fn has_table_cells(&self) -> bool {
645
+ self.borrow()
646
+ .stats
647
+ .as_ref()
648
+ .is_some_and(|s| s.num_table_cells.is_some())
649
+ }
650
+
671
651
  pub fn get_view_config(&'_ self) -> Ref<'_, ViewConfig> {
672
652
  Ref::map(self.borrow(), |x| &x.config)
673
653
  }
@@ -924,15 +904,6 @@ impl Session {
924
904
  /// Emit `view_config_changed` coalesced to one event per microtask batch.
925
905
  /// Stats are cleared synchronously per commit (idempotent — an
926
906
  /// already-empty cache no-ops, so rapid commit sequences fetch once).
927
- ///
928
- /// CONTRACT: this channel is COALESCED and DEFERRED — N same-batch
929
- /// commits produce ONE emission, on a microtask AFTER the mutation.
930
- /// Subscribers must be batch-tolerant snapshot refreshers (they re-read
931
- /// live state; today: the root `SessionProps` snapshot and the debug
932
- /// panel). It is NOT suitable for per-commit accounting — pairing its
933
- /// edges against any other event stream WILL drift (the stuck-spinner
934
- /// regression, `UPDATE_COUNT_REGRESSION_PLAN.md`); run accounting is
935
- /// [`Self::begin_config_run`]'s RAII token instead.
936
907
  fn notify_view_config_changed(&self) {
937
908
  self.clear_column_stats();
938
909
  if !self.0.config_event_scheduled.replace(true) {
@@ -1218,6 +1189,8 @@ impl Session {
1218
1189
  if let Some(cb) = self.on_stats_changed.borrow().as_ref() {
1219
1190
  cb.emit(());
1220
1191
  }
1192
+
1193
+ self.stats_changed.emit(());
1221
1194
  }
1222
1195
 
1223
1196
  fn all_columns(&self) -> Vec<String> {
@@ -1258,7 +1231,10 @@ impl Session {
1258
1231
 
1259
1232
  SessionProps {
1260
1233
  config,
1261
- stats: data.stats.clone(),
1234
+ has_table_cells: data
1235
+ .stats
1236
+ .as_ref()
1237
+ .is_some_and(|s| s.num_table_cells.is_some()),
1262
1238
  has_table: if data.table.is_some() {
1263
1239
  Some(TableLoadState::Loaded)
1264
1240
  } else if data.is_loading {
@@ -1290,15 +1266,7 @@ pub struct ConfigSnapshot {
1290
1266
  pub struct ValidatedSnapshot(ConfigSnapshot);
1291
1267
 
1292
1268
  /// Type-state witness that a `View` is NEW for the plugin about to render
1293
- /// it — `plugin.draw()`'s contract (see `PLUGIN_DRAW_INVARIANT_PLAN.md`:
1294
- /// `draw` fires iff there is a new `View`, never otherwise). Minted in
1295
- /// exactly two places: [`Session::bind_view`]'s REBUILD arm (a new engine
1296
- /// `View` was constructed) and
1297
- /// [`crate::renderer::Renderer::promote_first_paint`] (a freshly-selected
1298
- /// plugin owes its first paint of the bound `View`). Do NOT construct it
1299
- /// anywhere else — `Renderer::draw_fresh` is the only consumer, and this
1300
- /// witness is what makes "full draw without a new `View`" (the stacked-chart
1301
- /// activation regression) uncompilable.
1269
+ /// it.
1302
1270
  pub struct FreshView(View);
1303
1271
 
1304
1272
  impl FreshView {