@perspective-dev/viewer 4.5.2 → 5.0.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 (178) hide show
  1. package/dist/cdn/perspective-viewer.js +20 -1
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  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/esm/extensions.d.ts +22 -3
  17. package/dist/esm/perspective-viewer.d.ts +9 -0
  18. package/dist/esm/perspective-viewer.inline.js +20 -1
  19. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  20. package/dist/esm/perspective-viewer.js +20 -1
  21. package/dist/esm/perspective-viewer.js.map +4 -4
  22. package/dist/esm/plugin.d.ts +71 -5
  23. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  24. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  25. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  26. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  27. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  28. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  29. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  30. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  31. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +39 -0
  32. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  33. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  34. package/dist/esm/ts-rs/ViewerConfig.d.ts +6 -2
  35. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  36. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  37. package/dist/wasm/perspective-viewer.d.ts +211 -77
  38. package/dist/wasm/perspective-viewer.js +528 -180
  39. package/dist/wasm/perspective-viewer.wasm +0 -0
  40. package/dist/wasm/perspective-viewer.wasm.d.ts +34 -26
  41. package/dist/wasm/snippets/perspective-viewer-daeef4ceb066e6c7/inline0.js +6 -0
  42. package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline3.js → perspective-viewer-daeef4ceb066e6c7/inline4.js} +5 -0
  43. package/package.json +3 -2
  44. package/src/css/_context_menu.css +14 -0
  45. package/src/css/column-settings-panel.css +1 -1
  46. package/src/css/containers/context-menu.css +101 -0
  47. package/src/css/panel-tab.css +193 -0
  48. package/src/css/plugin-selector.css +7 -10
  49. package/src/css/status-bar.css +75 -81
  50. package/src/css/viewer.css +180 -70
  51. package/src/rust/components/column_dropdown.rs +40 -30
  52. package/src/rust/components/column_selector/active_column.rs +7 -18
  53. package/src/rust/components/column_selector/aggregate_selector.rs +4 -7
  54. package/src/rust/components/column_selector/config_selector.rs +33 -81
  55. package/src/rust/components/column_selector/filter_column.rs +5 -15
  56. package/src/rust/components/column_selector/inactive_column.rs +3 -8
  57. package/src/rust/components/column_selector/sort_column.rs +3 -6
  58. package/src/rust/components/column_selector.rs +5 -11
  59. package/src/rust/components/containers/split_panel.rs +175 -62
  60. package/src/rust/components/context_menu.rs +130 -0
  61. package/src/rust/components/form/debug.rs +3 -0
  62. package/src/rust/components/global_filter_bar.rs +76 -0
  63. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  64. package/src/rust/components/main_panel/msg.rs +62 -0
  65. package/src/rust/components/main_panel/presize.rs +220 -0
  66. package/src/rust/components/main_panel/reconcile.rs +224 -0
  67. package/src/rust/components/main_panel/render.rs +385 -0
  68. package/src/rust/components/main_panel/update.rs +184 -0
  69. package/src/rust/components/main_panel.rs +311 -71
  70. package/src/rust/components/mod.rs +4 -0
  71. package/src/rust/components/panel_menu.rs +486 -0
  72. package/src/rust/components/panel_tab.rs +505 -0
  73. package/src/rust/components/plugin_tab.rs +1 -1
  74. package/src/rust/components/settings_panel.rs +13 -5
  75. package/src/rust/components/status_bar.rs +110 -178
  76. package/src/rust/components/status_indicator.rs +6 -9
  77. package/src/rust/components/style/mod.rs +5 -0
  78. package/src/rust/components/style/style_provider.rs +2 -0
  79. package/src/rust/components/viewer/filters.rs +133 -0
  80. package/src/rust/components/viewer/msg.rs +180 -0
  81. package/src/rust/components/viewer/panels.rs +326 -0
  82. package/src/rust/components/viewer/render.rs +243 -0
  83. package/src/rust/components/viewer/settings.rs +402 -0
  84. package/src/rust/components/viewer/snapshots.rs +134 -0
  85. package/src/rust/components/viewer/wiring.rs +505 -0
  86. package/src/rust/components/viewer.rs +243 -756
  87. package/src/rust/config/export_method.rs +1 -1
  88. package/src/rust/config/mod.rs +4 -0
  89. package/src/rust/config/options.rs +71 -0
  90. package/src/rust/config/viewer_config.rs +24 -2
  91. package/src/rust/config/workspace_config.rs +92 -0
  92. package/src/rust/custom_elements/copy_dropdown.rs +3 -1
  93. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  94. package/src/rust/custom_elements/export_dropdown.rs +3 -1
  95. package/src/rust/custom_elements/viewer.rs +1015 -286
  96. package/src/rust/custom_events.rs +212 -61
  97. package/src/rust/js/mod.rs +2 -0
  98. package/src/rust/js/plugin.rs +297 -10
  99. package/src/rust/js/regular_layout.rs +258 -0
  100. package/src/rust/lib.rs +20 -1
  101. package/src/rust/presentation.rs +66 -33
  102. package/src/rust/queries/get_viewer_config.rs +29 -11
  103. package/src/rust/queries/plugin_column_styles.rs +2 -2
  104. package/src/rust/renderer/activate.rs +41 -14
  105. package/src/rust/renderer/dispatch.rs +436 -0
  106. package/src/rust/renderer/plugin_config.rs +495 -0
  107. package/src/rust/renderer/registry.rs +18 -6
  108. package/src/rust/renderer/render_timer.rs +29 -10
  109. package/src/rust/renderer.rs +350 -837
  110. package/src/rust/session/column_defaults_update.rs +26 -8
  111. package/src/rust/session/view_subscription.rs +30 -15
  112. package/src/rust/session.rs +753 -264
  113. package/src/rust/tasks/apply_global_filters.rs +89 -0
  114. package/src/rust/tasks/auto_pause.rs +171 -0
  115. package/src/rust/tasks/copy_export.rs +29 -19
  116. package/src/rust/tasks/create_panel.rs +243 -0
  117. package/src/rust/tasks/dismiss_render_warning.rs +3 -1
  118. package/src/rust/tasks/edit_expression.rs +4 -4
  119. package/src/rust/tasks/eject.rs +64 -23
  120. package/src/rust/tasks/mod.rs +28 -4
  121. package/src/rust/tasks/pipeline.rs +411 -0
  122. package/src/rust/tasks/presize_panels.rs +324 -0
  123. package/src/rust/tasks/reset_all.rs +22 -12
  124. package/src/rust/tasks/resize_observer.rs +124 -87
  125. package/src/rust/tasks/restore_and_render.rs +80 -86
  126. package/src/rust/tasks/restore_panel.rs +145 -0
  127. package/src/rust/tasks/send_column_config.rs +5 -2
  128. package/src/rust/tasks/send_plugin_config.rs +5 -2
  129. package/src/rust/tasks/set_edit_mode.rs +59 -0
  130. package/src/rust/tasks/sync_update_panels.rs +152 -0
  131. package/src/rust/tasks/table_lifecycle.rs +158 -0
  132. package/src/rust/tasks/update_theme.rs +56 -12
  133. package/src/rust/utils/completion.rs +56 -0
  134. package/src/rust/utils/custom_element.rs +5 -0
  135. package/src/rust/utils/debounce.rs +130 -7
  136. package/src/rust/utils/mod.rs +6 -0
  137. package/src/rust/utils/pending_effects.rs +62 -0
  138. package/src/rust/utils/spawn.rs +33 -0
  139. package/src/rust/utils/weak_scope.rs +0 -10
  140. package/src/rust/workspace.rs +846 -0
  141. package/src/svg/active-indicator.svg +3 -0
  142. package/src/themes/botanical.css +5 -2
  143. package/src/themes/defaults.css +54 -2
  144. package/src/themes/dracula.css +6 -3
  145. package/src/themes/gruvbox-dark.css +5 -2
  146. package/src/themes/gruvbox.css +5 -2
  147. package/src/themes/icons.css +4 -2
  148. package/src/themes/monokai.css +5 -2
  149. package/src/themes/phosphor.css +5 -2
  150. package/src/themes/pro-dark.css +5 -2
  151. package/src/themes/pro.css +2 -1
  152. package/src/themes/solarized-dark.css +5 -2
  153. package/src/themes/solarized.css +5 -2
  154. package/src/themes/vaporwave.css +5 -2
  155. package/src/ts/bootstrap.ts +8 -7
  156. package/src/ts/extensions.ts +30 -2
  157. package/src/ts/perspective-viewer.ts +10 -0
  158. package/src/ts/plugin.ts +72 -5
  159. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  160. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  161. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  162. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  163. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  164. package/src/ts/ts-rs/Layout.ts +9 -0
  165. package/src/ts/ts-rs/Orientation.ts +6 -0
  166. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  167. package/src/ts/ts-rs/PanelViewerConfig.ts +15 -0
  168. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  169. package/src/ts/ts-rs/TabLayout.ts +11 -0
  170. package/src/ts/ts-rs/ViewerConfig.ts +6 -2
  171. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  172. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  173. package/src/rust/tasks/intersection_observer.rs +0 -98
  174. package/src/rust/tasks/update_and_render.rs +0 -110
  175. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline0.js → perspective-viewer-daeef4ceb066e6c7/inline1.js} +0 -0
  176. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline1.js → perspective-viewer-daeef4ceb066e6c7/inline2.js} +0 -0
  177. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline2.js → perspective-viewer-daeef4ceb066e6c7/inline3.js} +0 -0
  178. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline4.js → perspective-viewer-daeef4ceb066e6c7/inline5.js} +0 -0
@@ -18,7 +18,9 @@
18
18
  //! references throughout the application.
19
19
 
20
20
  mod activate;
21
+ mod dispatch;
21
22
  pub mod limits;
23
+ mod plugin_config;
22
24
  mod plugin_store;
23
25
  mod props;
24
26
  mod registry;
@@ -26,17 +28,11 @@ mod render_timer;
26
28
 
27
29
  use std::cell::{Cell, RefCell};
28
30
  use std::collections::HashMap;
29
- use std::future::Future;
30
31
  use std::ops::Deref;
31
- use std::pin::Pin;
32
32
  use std::rc::Rc;
33
33
 
34
- use futures::future::{join_all, select_all};
35
- use perspective_client::config::ViewConfig;
36
- use perspective_client::utils::*;
37
- use perspective_client::{View, ViewWindow};
38
- use perspective_js::utils::{ApiResult, JsValueSerdeExt, ResultTApiErrorExt};
39
- use serde_json::Value;
34
+ use perspective_client::ViewWindow;
35
+ use perspective_js::utils::{ApiFuture, ApiResult, JsValueSerdeExt};
40
36
  use wasm_bindgen::prelude::*;
41
37
  use web_sys::*;
42
38
  use yew::html::ImplicitClone;
@@ -44,31 +40,43 @@ use yew::prelude::*;
44
40
 
45
41
  use self::activate::*;
46
42
  pub use self::limits::RenderLimits;
47
- use self::limits::*;
43
+ pub use self::plugin_config::{ColumnConfigMap, PluginScopedConfig};
48
44
  use self::plugin_store::*;
49
45
  pub use self::props::RendererProps;
50
46
  pub use self::registry::*;
51
47
  use self::render_timer::*;
52
48
  use crate::config::*;
53
49
  use crate::js::plugin::*;
54
- use crate::queries::resolve_abs_max;
55
- use crate::session::Session;
56
50
  use crate::utils::*;
57
51
 
58
- /// A per-column config map. Each inner [`serde_json::Map`] is a flat collection
59
- /// of plugin-defined JSON keys whose shape is dictated by the active plugin's
60
- /// [`crate::config::ColumnConfigSchema`].
61
- pub type ColumnConfigMap = HashMap<String, serde_json::Map<String, serde_json::Value>>;
62
-
63
- /// Per-plugin config bucket. Holds the per-column style map and the
64
- /// plugin-level config map for one plugin. Buckets are keyed by plugin
65
- /// name in [`RendererMutData::plugin_states`], so foreign keys from a
66
- /// different plugin physically cannot appear in the active plugin's
67
- /// bucket.
68
- #[derive(Clone, Debug, Default)]
69
- pub struct PluginScopedConfig {
70
- pub columns: ColumnConfigMap,
71
- pub plugin: serde_json::Map<String, serde_json::Value>,
52
+ /// Minimum geometry delta (px) considered a real size/position change by the
53
+ /// presize paths.
54
+ pub(crate) const SUBPIXEL_EPSILON: f64 = 0.5;
55
+
56
+ /// Everything a plugin may read back during its render, all belonging to
57
+ /// one snapshot (invariant I5). Built once per bound `View` by the render
58
+ /// pipeline and CACHED on the `Renderer`; pinning it for a run is an `Rc`
59
+ /// clone (cheap enough for resize-tick-rate runs). While pinned, the
60
+ /// per-panel element getters (`getViewConfigPanel`, `getTablePanel`,
61
+ /// `getClientPanel`, `getEditPortPanel`) answer from it — the plugin ABI is
62
+ /// inside the snapshot boundary.
63
+ pub struct RenderContext {
64
+ pub view_config: Rc<perspective_client::config::ViewConfig>,
65
+ pub view: perspective_client::View,
66
+ pub table: perspective_client::Table,
67
+ pub client: perspective_client::Client,
68
+ pub edit_port: Option<f64>,
69
+ pub theme: Option<String>,
70
+ }
71
+
72
+ /// RAII pin for a [`RenderContext`] — cleared on drop, INCLUDING error
73
+ /// exits, so a failed run can't leak a stale context.
74
+ pub struct ContextPin(Renderer);
75
+
76
+ impl Drop for ContextPin {
77
+ fn drop(&mut self) {
78
+ self.0.0.active_context.borrow_mut().take();
79
+ }
72
80
  }
73
81
 
74
82
  /// Immutable state
@@ -92,8 +100,84 @@ pub struct RendererData {
92
100
  /// dismissable.
93
101
  pub render_warning: Cell<bool>,
94
102
 
103
+ /// Count of in-flight [`Renderer::resize_with_dimensions`] /
104
+ /// [`Renderer::presize_with_box`] presize calls.
105
+ presize_pending: Cell<u32>,
106
+
95
107
  /// Fires after every draw/update with the computed render limits.
96
108
  pub on_render_limits_changed: RefCell<Option<Callback<RenderLimits>>>,
109
+
110
+ /// The layout slot name (panel id) under which this renderer mounts its
111
+ /// plugin in the viewer's light DOM, so multiple panels' plugins can
112
+ /// coexist there.
113
+ slot_name: RefCell<Option<String>>,
114
+
115
+ /// This panel's selected theme name (per-panel theming), or `None` to
116
+ /// inherit the element-level (active) theme.
117
+ theme: RefCell<Option<String>>,
118
+
119
+ /// The registry default theme name (first registered), cached here so
120
+ /// LOCKED draw paths can resolve this panel's EFFECTIVE theme
121
+ /// synchronously (`Presentation::get_default_theme_name` is async — it
122
+ /// awaits theme discovery). Seeded by every content-load path before its
123
+ /// first locked draw (`restore_and_render`, `load()`, the
124
+ /// resize-observer's deferred first render) and kept fresh by the root's
125
+ /// `UpdatePresentation` default-theme fan-out and `resetThemes`.
126
+ default_theme: RefCell<Option<String>>,
127
+
128
+ /// Whether the active plugin has completed a draw. An EXPLICIT flag —
129
+ /// not inferred from DOM connectedness — because plugin elements may be
130
+ /// mounted eagerly (at panel creation / draw start, before the view
131
+ /// query resolves), so "in the DOM" no longer implies "has rendered".
132
+ /// Set by a successful `draw_view`; cleared on plugin swap
133
+ /// (`commit_plugin_idx`), `dispose` and `delete`.
134
+ has_drawn: Cell<bool>,
135
+
136
+ /// The effective theme stamped at the active plugin's last `--psp-*`
137
+ /// CSS CAPTURE — its first paint ([`Renderer::draw_view`]) or last
138
+ /// [`Renderer::restyle_all`]. `None` = no capture has happened. The
139
+ /// state behind [`Renderer::needs_restyle`]: restyle necessity is a
140
+ /// comparison against what the plugin actually captured, never
141
+ /// inferred from which call site mutated a theme record (the
142
+ /// raycasting boot double render — see
143
+ /// `PLUGIN_DRAW_INVARIANT_PLAN.md`, captured-theme revision). Same
144
+ /// lifecycle as [`Self::has_drawn`]: cleared on plugin swap,
145
+ /// `dispose` and `delete`; written only inside locked dispatches.
146
+ captured_theme: RefCell<Option<Option<String>>>,
147
+
148
+ /// The [`RenderContext`] of the currently-bound `View` (built at bind
149
+ /// time by the pipeline). Cleared on `dispose`/`delete`.
150
+ cached_context: RefCell<Option<Rc<RenderContext>>>,
151
+
152
+ /// The [`RenderContext`] pinned by the run currently holding the draw
153
+ /// lock, if any (invariant I5). Managed by [`ContextPin`].
154
+ active_context: RefCell<Option<Rc<RenderContext>>>,
155
+
156
+ /// Whether this renderer's panel is the workspace's ACTIVE panel. Pure
157
+ /// DATA, written synchronously by `Workspace::set_active`/`insert_panel`;
158
+ /// the `active` CSS class it drives is applied ONLY inside locked plugin
159
+ /// dispatches ([`Renderer::stamp_active`], "stamp before draw" — like the
160
+ /// theme), so activation-dependent chrome and the DOM it styles always
161
+ /// land in one paint commit.
162
+ is_active_panel: Cell<bool>,
163
+
164
+ /// Whether this renderer's panel is the workspace's ONLY panel. Pure DATA
165
+ /// like [`Self::is_active_panel`], written synchronously by the
166
+ /// `Workspace` panel-count mutation sites (`new`/`insert_panel`/
167
+ /// `remove_panel`); the `single`/`multi` CSS classes it drives are
168
+ /// applied ONLY inside locked plugin dispatches
169
+ /// ([`Renderer::stamp_active`]), for the same one-paint-commit reason.
170
+ is_solo_panel: Cell<bool>,
171
+
172
+ /// A `table_updated` redraw was DROPPED because this panel's plugin was
173
+ /// hidden (an unslotted tab-stack panel — see
174
+ /// `tasks::create_panel::wire_panel_render_sub`), so the plugin's
175
+ /// retained frame no longer reflects the bound `View`'s data. The
176
+ /// activation nudge consumes it to dispatch a full `plugin.update`
177
+ /// instead of the usual chrome-only `resize` repaint. Cleared at every
178
+ /// `draw_view` dispatch — set BEFORE the plugin's data fetch, so an
179
+ /// update landing mid-draw re-marks it and can never be lost.
180
+ data_stale: Cell<bool>,
97
181
  }
98
182
 
99
183
  /// Mutable state
@@ -104,9 +188,6 @@ pub struct RendererMutData {
104
188
  plugins_idx: Option<usize>,
105
189
  timer: MovingWindowRenderTimer,
106
190
  selection: Option<ViewWindow>,
107
- pending_plugin: Option<usize>,
108
-
109
- /// Per-plugin config buckets, keyed by plugin name.
110
191
  plugin_states: HashMap<String, PluginScopedConfig>,
111
192
  }
112
193
 
@@ -138,12 +219,6 @@ impl Deref for RendererData {
138
219
  }
139
220
  }
140
221
 
141
- type TaskResult = ApiResult<JsValue>;
142
- type TimeoutTask<'a> = Pin<Box<dyn Future<Output = Option<TaskResult>> + 'a>>;
143
-
144
- /// How long to await a call to the plugin's `draw()` before resizing.
145
- static PRESIZE_TIMEOUT: i32 = 500;
146
-
147
222
  impl Renderer {
148
223
  pub fn new(viewer_elem: &HtmlElement) -> Self {
149
224
  Self(Rc::new(RendererData {
@@ -154,7 +229,6 @@ impl Renderer {
154
229
  plugins_idx: None,
155
230
  selection: None,
156
231
  timer: MovingWindowRenderTimer::default(),
157
- pending_plugin: None,
158
232
  plugin_states: HashMap::default(),
159
233
  }),
160
234
  draw_lock: Default::default(),
@@ -165,524 +239,176 @@ impl Renderer {
165
239
  column_style_changed: Default::default(),
166
240
  plugin_config_changed: Default::default(),
167
241
  render_warning: Cell::new(true),
242
+ presize_pending: Cell::new(0),
168
243
  on_render_limits_changed: Default::default(),
244
+ slot_name: Default::default(),
245
+ theme: Default::default(),
246
+ default_theme: Default::default(),
247
+ has_drawn: Cell::new(false),
248
+ captured_theme: Default::default(),
249
+ cached_context: Default::default(),
250
+ active_context: Default::default(),
251
+ is_active_panel: Cell::new(false),
252
+ is_solo_panel: Cell::new(true),
253
+ data_stale: Cell::new(false),
169
254
  }))
170
255
  }
171
256
 
172
- pub fn delete(&self) -> ApiResult<()> {
173
- self.get_active_plugin().map(|x| x.delete()).unwrap_or_log();
174
- self.plugin_data.borrow().viewer_elem.set_inner_text("");
175
- let new_state = Self::new(&self.plugin_data.borrow().viewer_elem);
176
- std::mem::swap(
177
- &mut *self.plugin_data.borrow_mut(),
178
- &mut *new_state.plugin_data.borrow_mut(),
179
- );
180
-
181
- Ok(())
257
+ /// Set the layout slot name (panel id) under which this renderer mounts its
258
+ /// plugin in the viewer's light DOM, routed through the matching
259
+ /// `<regular-layout>` forwarding slot.
260
+ pub fn set_slot_name(&self, name: &str) {
261
+ *self.0.slot_name.borrow_mut() = Some(name.to_owned());
182
262
  }
183
263
 
184
- pub fn metadata(&self) -> Rc<PluginStaticConfig> {
185
- self.borrow().metadata.clone()
264
+ /// The layout slot name (panel id) for this renderer's plugin, if assigned.
265
+ pub fn slot_name(&self) -> Option<String> {
266
+ self.0.slot_name.borrow().clone()
186
267
  }
187
268
 
188
- pub fn is_chart(&self) -> bool {
189
- self.metadata().name.as_str() != "Datagrid"
190
- }
191
-
192
- /// Whether the active plugin opts into per-column style controls.
193
- pub fn can_render_column_styles(&self) -> bool {
194
- self.metadata().can_render_column_styles
269
+ /// Set this panel's theme name (`None` = inherit the element-level theme).
270
+ pub fn set_theme(&self, name: Option<String>) {
271
+ *self.0.theme.borrow_mut() = name;
195
272
  }
196
273
 
197
- /// Name of the currently-active plugin (used as the key into
198
- /// `plugin_states`). Returns `None` when no plugin has been
199
- /// activated yet.
200
- fn active_plugin_name(&self) -> Option<String> {
201
- Some(self.borrow().metadata.name.clone()).filter(|n| !n.is_empty())
274
+ /// This panel's selected theme name, if any (per-panel theming).
275
+ pub fn theme(&self) -> Option<String> {
276
+ self.0.theme.borrow().clone()
202
277
  }
203
278
 
204
- // ─── Per-column config (active plugin's bucket) ───────────────────
205
-
206
- /// Snapshot of the active plugin's per-column config map.
207
- pub fn all_columns_configs(&self) -> ColumnConfigMap {
208
- self.active_plugin_name()
209
- .and_then(|n| {
210
- self.borrow()
211
- .plugin_states
212
- .get(&n)
213
- .map(|b| b.columns.clone())
214
- })
215
- .unwrap_or_default()
216
- }
217
-
218
- /// Restore-prep snapshot: like [`Self::all_columns_configs`], but
219
- /// for each column also materializes any `ControlSpec::Number`
220
- /// fields the schema declares with `include: true` that aren't
221
- /// already in the bucket entry. The materialized value is the
222
- /// schema's `default`, which the schema computes from cached
223
- /// column stats (via [`Self::query_column_config_schema`]).
224
- ///
225
- /// The bucket itself stays minimal (user edits + `include: true`
226
- /// values the user *explicitly set*); this helper produces the
227
- /// fully-realized payload the plugin's `restore` is expected to
228
- /// receive. Every restore-prep site should call this rather than
229
- /// `all_columns_configs` directly — otherwise widgets that gate
230
- /// `include` fields off other fields (e.g. Datagrid's
231
- /// `fg_gradient` revealed when `number_fg_mode = "bar"`) will
232
- /// reach the plugin without their default value populated.
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(
244
- &self,
245
- view_config: &ViewConfig,
246
- session: &Session,
247
- ) -> ColumnConfigMap {
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`.
296
- for (col, entry) in &mut configs {
297
- let Ok(schema) =
298
- self.query_column_config_schema(view_config, session, col, Some(entry))
299
- else {
300
- continue;
301
- };
302
-
303
- for field in &schema.fields {
304
- let ControlSpec::Number {
305
- key,
306
- default,
307
- include: Some(true),
308
- ..
309
- } = field
310
- else {
311
- continue;
312
- };
313
-
314
- if entry.contains_key(key) {
315
- continue;
316
- }
317
-
318
- let Some(num) = serde_json::Number::from_f64(*default) else {
319
- continue;
320
- };
321
-
322
- entry.insert(key.clone(), serde_json::Value::Number(num));
323
- }
279
+ /// [`Self::set_theme`] plus a synchronous [`Self::stamp_theme`] when the
280
+ /// effective theme is resolvable now (a named theme needs no registry; a
281
+ /// reset stamps only from a warm default cache — a cold one would stamp
282
+ /// attribute-removal). The shared "stamp-with-commit" head of every
283
+ /// per-panel theme mutation site.
284
+ pub fn set_theme_stamped(&self, theme: Option<String>) {
285
+ let stamp = theme.is_some() || self.default_theme().is_some();
286
+ self.set_theme(theme);
287
+ if stamp {
288
+ self.stamp_theme(None);
324
289
  }
325
-
326
- configs
327
290
  }
328
291
 
329
- /// Clear the active plugin's per-column config map.
330
- pub fn reset_columns_configs(&self) {
331
- if let Some(n) = self.active_plugin_name() {
332
- self.borrow_mut()
333
- .plugin_states
334
- .entry(n)
335
- .or_default()
336
- .columns
337
- .clear();
338
- }
292
+ /// Set the cached registry default theme name (see the field docs on
293
+ /// [`RendererData`]).
294
+ pub fn set_default_theme(&self, name: Option<String>) {
295
+ *self.0.default_theme.borrow_mut() = name;
339
296
  }
340
297
 
341
- /// Clone of the active plugin's per-column entry for `column_name`,
342
- /// or `None` if no value is stored.
343
- pub fn get_columns_config(
344
- &self,
345
- column_name: &str,
346
- ) -> Option<serde_json::Map<String, serde_json::Value>> {
347
- let n = self.active_plugin_name()?;
348
- self.borrow()
349
- .plugin_states
350
- .get(&n)?
351
- .columns
352
- .get(column_name)
353
- .cloned()
298
+ /// The cached registry default theme name.
299
+ pub fn default_theme(&self) -> Option<String> {
300
+ self.0.default_theme.borrow().clone()
354
301
  }
355
302
 
356
- /// Wholesale update the active plugin's per-column config map
357
- /// (e.g. from a `restore()` call). Each incoming column entry is
358
- /// schema-stripped before insertion — values matching the
359
- /// schema-declared default are dropped so the bucket converges to
360
- /// the "empty ⇒ reads-default" invariant, mirroring
361
- /// [`Self::update_plugin_config`]. `ControlSpec::Number` fields
362
- /// declared with `include: true` survive the strip (their default
363
- /// is data-dependent, so a literal default value is preserved as
364
- /// the user's explicit choice). Column entries that become empty
365
- /// after stripping are removed from the bucket entirely.
366
- pub fn update_columns_configs(
367
- &self,
368
- view_config: &ViewConfig,
369
- session: &Session,
370
- update: ColumnConfigUpdate,
371
- ) -> bool {
372
- let Some(n) = self.active_plugin_name() else {
373
- return false;
374
- };
375
-
376
- match update {
377
- OptionalUpdate::SetDefault => {
378
- let mut st = self.borrow_mut();
379
- let bucket = st.plugin_states.entry(n).or_default();
380
- let was_nonempty = !bucket.columns.is_empty();
381
- bucket.columns.clear();
382
- was_nonempty
383
- },
384
- OptionalUpdate::Missing => false,
385
- OptionalUpdate::Update(map) => {
386
- // Strip per-column before borrowing the bucket mutably:
387
- // the schema query takes an immutable borrow via
388
- // `self.metadata()`, which would alias-conflict with
389
- // `borrow_mut` below.
390
- let stripped: Vec<(String, serde_json::Map<String, serde_json::Value>)> = map
391
- .into_iter()
392
- .map(|(col, mut cfg)| {
393
- if let Ok(schema) =
394
- self.query_column_config_schema(view_config, session, &col, Some(&cfg))
395
- {
396
- let active = schema.active_keys();
397
- cfg.retain(|k, _| active.contains(k));
398
- strip_default_values(&schema, &mut cfg);
399
- }
400
-
401
- (col, cfg)
402
- })
403
- .collect();
404
-
405
- let mut st = self.borrow_mut();
406
- let bucket = st.plugin_states.entry(n).or_default();
407
- let mut changed = false;
408
- for (col, cfg) in stripped {
409
- if cfg.is_empty() {
410
- if bucket.columns.remove(&col).is_some() {
411
- changed = true;
412
- }
413
- } else {
414
- match bucket.columns.insert(col, cfg.clone()) {
415
- None => changed = true,
416
- Some(old) if old != cfg => changed = true,
417
- _ => {},
418
- }
419
- }
420
- }
303
+ /// This panel's EFFECTIVE theme: its own ([`Self::theme`]), else the
304
+ /// cached registry default ([`Self::default_theme`]) — the value
305
+ /// [`Self::stamp_theme`] stamps.
306
+ pub fn effective_theme(&self) -> Option<String> {
307
+ self.theme().or_else(|| self.default_theme())
308
+ }
421
309
 
422
- changed
423
- },
310
+ /// Whether the active plugin's captured `--psp-*` CSS is STALE — the
311
+ /// effective theme differs from the one stamped at the plugin's last
312
+ /// CSS capture (first paint / last restyle). The state-keyed gate for
313
+ /// every theme-driven `restyle_all` (the `has_drawn` analog for
314
+ /// `restyle` — `PLUGIN_DRAW_INVARIANT_PLAN.md`, captured-theme
315
+ /// revision): a caller that just mutated a theme RECORD may not need a
316
+ /// restyle at all if the plugin's capture already reflects the new
317
+ /// value (e.g. the same restore also performed the first paint,
318
+ /// post-stamp). `false` when no capture exists yet — the owed first
319
+ /// paint captures fresh by construction ("stamp before draw").
320
+ pub fn needs_restyle(&self) -> bool {
321
+ match &*self.0.captured_theme.borrow() {
322
+ Some(captured) => *captured != self.effective_theme(),
323
+ None => false,
424
324
  }
425
325
  }
426
326
 
427
- /// Apply a single schema-field update from the column-style UI to
428
- /// the active plugin's bucket. Clears the keys the field owns,
429
- /// then splices in the partial new sub-state. Drops empty
430
- /// entries.
327
+ /// Dispose a single panel's renderer: delete its active plugin and remove
328
+ /// this panel's element(s) from the viewer's light DOM, scoped by
329
+ /// `slot_name` so sibling panels are untouched (unlike
330
+ /// [`Self::delete`], which clears the entire light DOM). Removes the
331
+ /// plugin (`slot=<id>`) and any panel-scoped aux element it mounted
332
+ /// (e.g. the datagrid toolbar, `slot=statusbar-extra-<id>`).
431
333
  ///
432
- /// The schema-strip is defense-in-depth: widget callbacks (e.g.
433
- /// `NumberFieldPrimitive`) already pre-strip default values, so
434
- /// for live edits this strip pass is a no-op. It closes the hole
435
- /// for programmatic callers that construct a
436
- /// `ColumnConfigFieldUpdate` directly without going through the
437
- /// widget (where `include: true` would otherwise be ignored).
438
- pub fn update_columns_config_field(
439
- &self,
440
- view_config: &ViewConfig,
441
- session: &Session,
442
- column_name: String,
443
- mut update: ColumnConfigFieldUpdate,
444
- ) {
445
- let Some(n) = self.active_plugin_name() else {
446
- return;
447
- };
448
-
449
- // Take the schema query before the mutable borrow — same
450
- // RefCell aliasing reason as in `update_columns_configs`.
451
- let current_value = self.get_columns_config(&column_name);
452
- if let Ok(schema) = self.query_column_config_schema(
453
- view_config,
454
- session,
455
- &column_name,
456
- current_value.as_ref(),
457
- ) {
458
- strip_default_values(&schema, &mut update.value);
459
- }
334
+ /// The teardown is DEFERRED through this renderer's draw lock: spawned
335
+ /// draws are uncancellable, so a synchronous `plugin.delete()` here could
336
+ /// land mid-`plugin.draw()`, which the serialized-call contract forbids
337
+ /// (see [`crate::js::plugin`]). Deferral is invisible — the caller
338
+ /// (`eject_panel`) has already removed the panel from the `Workspace`, so
339
+ /// the same commit unmounts its frame and the plugin element sits
340
+ /// unslotted (unrendered) until teardown lands; panel ids are never
341
+ /// reused, so it can't collide with a replacement panel's slot. Draws
342
+ /// still queued behind the lock no-op via the synchronous session reset
343
+ /// (`get_view()` → `None`).
344
+ pub fn dispose(&self) -> ApiFuture<()> {
345
+ self.0.has_drawn.set(false);
346
+ self.0.captured_theme.borrow_mut().take();
347
+ self.0.cached_context.borrow_mut().take();
348
+ let this = self.clone();
349
+ ApiFuture::new(async move {
350
+ let renderer = this.clone();
351
+ this.with_lock(async move {
352
+ if let Some(plugin) = renderer.active_plugin() {
353
+ plugin.delete();
354
+ }
460
355
 
461
- let mut st = self.borrow_mut();
462
- let bucket = st.plugin_states.entry(n).or_default();
463
- let entry = bucket.columns.entry(column_name.clone()).or_default();
464
- for k in &update.keys {
465
- entry.remove(k);
466
- }
467
- for (k, v) in update.value {
468
- if update.keys.contains(&k) {
469
- entry.insert(k, v);
470
- }
471
- }
472
- if entry.is_empty() {
473
- bucket.columns.remove(&column_name);
474
- }
475
- }
356
+ let Some(slot) = renderer.slot_name() else {
357
+ return Ok(());
358
+ };
476
359
 
477
- // ─── Plugin-level config (active plugin's bucket) ─────────────────
360
+ let viewer = renderer.plugin_data.borrow().viewer_elem.clone();
361
+ let slots = [slot.clone(), format!("statusbar-extra-{slot}")];
362
+ let children = viewer.children();
363
+ let mut idx = children.length();
364
+ while idx > 0 {
365
+ idx -= 1;
366
+ if let Some(el) = children.item(idx)
367
+ && el.get_attribute("slot").is_some_and(|s| slots.contains(&s))
368
+ {
369
+ let _ = viewer.remove_child(&el);
370
+ }
371
+ }
478
372
 
479
- /// Snapshot of the active plugin's plugin-level config map.
480
- pub fn get_plugin_config(&self) -> serde_json::Map<String, serde_json::Value> {
481
- self.active_plugin_name()
482
- .and_then(|n| {
483
- self.borrow()
484
- .plugin_states
485
- .get(&n)
486
- .map(|b| b.plugin.clone())
373
+ Ok(())
487
374
  })
488
- .unwrap_or_default()
375
+ .await
376
+ })
489
377
  }
490
378
 
491
- /// Clear the active plugin's plugin-level config map.
492
- pub fn reset_plugin_config(&self) {
493
- if let Some(n) = self.active_plugin_name() {
494
- self.borrow_mut()
495
- .plugin_states
496
- .entry(n)
497
- .or_default()
498
- .plugin
499
- .clear();
379
+ pub fn delete(&self) -> ApiResult<()> {
380
+ self.0.has_drawn.set(false);
381
+ self.0.captured_theme.borrow_mut().take();
382
+ self.0.cached_context.borrow_mut().take();
383
+ if let Some(plugin) = self.active_plugin() {
384
+ plugin.delete();
500
385
  }
501
- }
386
+ self.plugin_data.borrow().viewer_elem.set_inner_text("");
387
+ let new_state = Self::new(&self.plugin_data.borrow().viewer_elem);
388
+ std::mem::swap(
389
+ &mut *self.plugin_data.borrow_mut(),
390
+ &mut *new_state.plugin_data.borrow_mut(),
391
+ );
502
392
 
503
- /// Synchronously query the active plugin's
504
- /// [`ColumnConfigSchema`] used to gate plugin-config strip logic.
505
- /// Inlined here (rather than calling `queries::get_plugin_config_schema`)
506
- /// to keep `renderer` from back-importing the `queries` module.
507
- fn query_plugin_config_schema(
508
- &self,
509
- view_config: &ViewConfig,
510
- ) -> ApiResult<ColumnConfigSchema> {
511
- let plugin = self.get_active_plugin()?;
512
- let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
513
- let raw = plugin._plugin_config_schema(&view_config_js)?;
514
- serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
393
+ Ok(())
515
394
  }
516
395
 
517
- /// Per-column counterpart of [`query_plugin_config_schema`]. Used by
518
- /// the columns-config write paths (strip-on-write) and the
519
- /// restore-prep snapshot (materialize-on-read).
520
- ///
521
- /// Reads the cached `ColumnStats` (cleared on `view_config_changed`)
522
- /// so plugins emit gradient defaults against the column's current
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.
530
- fn query_column_config_schema(
531
- &self,
532
- view_config: &ViewConfig,
533
- session: &Session,
534
- column_name: &str,
535
- current_value: Option<&serde_json::Map<String, serde_json::Value>>,
536
- ) -> ApiResult<ColumnConfigSchema> {
537
- let plugin = self.get_active_plugin()?;
538
- let plugin_config = self.metadata();
539
- let names = &plugin_config.config_column_names;
540
- let group = view_config
541
- .columns
542
- .iter()
543
- .position(|maybe_s| maybe_s.as_deref() == Some(column_name))
544
- .and_then(|idx| names.get(idx))
545
- .map(|s| s.as_str());
546
-
547
- // If the view schema itself hasn't been built yet (e.g. restore
548
- // landed before `create_view` populated `view_schema`), refuse
549
- // to answer rather than returning an empty schema. The strip
550
- // pass in `update_columns_configs` treats `Err` as "leave the
551
- // incoming cfg untouched"; an empty schema, by contrast, would
552
- // cause `cfg.retain(|k, _| active.contains(k))` to drop every
553
- // key and zero out user-supplied column config.
554
- if !session.metadata().has_view_schema() {
555
- return Err(JsValue::from("view_schema not initialized").into());
556
- }
557
- let Some(view_type) = session.metadata().get_column_view_type(column_name) else {
558
- return Ok(ColumnConfigSchema { fields: vec![] });
559
- };
560
-
561
- let current_js = JsValue::from_serde_ext(&current_value).unwrap_or(JsValue::NULL);
562
- let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
563
-
564
- // Pull the column's cached stats from the session. The StyleTab
565
- // pre-warms this via `fetch_column_abs_max` whenever the user
566
- // opens column settings; the cache is invalidated on every
567
- // `view_config_changed`, so freshness is bounded.
568
- let stats = session.get_column_stats(column_name).unwrap_or_default();
569
- let stats_json = serde_json::json!({
570
- "abs_max": stats.abs_max,
571
- });
572
- let stats_js = JsValue::from_serde_ext(&stats_json).unwrap_or(JsValue::NULL);
573
-
574
- let raw = plugin._column_config_schema(
575
- &view_type.to_string(),
576
- group,
577
- column_name,
578
- &current_js,
579
- &view_config_js,
580
- &stats_js,
581
- )?;
582
-
583
- serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
396
+ pub fn metadata(&self) -> Rc<PluginStaticConfig> {
397
+ self.borrow().metadata.clone()
584
398
  }
585
399
 
586
- /// Wholesale update the active plugin's plugin-level config map.
587
- /// Entries whose value equals the schema-declared default are
588
- /// treated as "reset this key" — the corresponding bucket entry
589
- /// is cleared rather than the default being stored literally.
590
- /// Keys absent from the incoming map are left alone (merge
591
- /// semantics for the non-default subset).
592
- pub fn update_plugin_config(
593
- &self,
594
- view_config: &ViewConfig,
595
- update: PluginConfigUpdate,
596
- ) -> bool {
597
- let Some(n) = self.active_plugin_name() else {
598
- return false;
599
- };
600
-
601
- let schema = self.query_plugin_config_schema(view_config).ok();
602
- let mut st = self.borrow_mut();
603
- let bucket = st.plugin_states.entry(n).or_default();
604
- match update {
605
- OptionalUpdate::SetDefault => {
606
- let changed = !bucket.plugin.is_empty();
607
- bucket.plugin.clear();
608
- changed
609
- },
610
- OptionalUpdate::Missing => false,
611
- OptionalUpdate::Update(mut map) => {
612
- let mut changed = false;
613
- if let Some(s) = &schema {
614
- let active = s.active_keys();
615
- map.retain(|k, _| active.contains(k));
616
- // Default-valued entries in a restore payload
617
- // semantically reset the key — strip from the
618
- // map AND clear any existing override in the
619
- // bucket so the wholesale-restore path matches
620
- // the live-edit path (where the widget emits an
621
- // empty value to clear).
622
- map.retain(|key, value| {
623
- let is_default = s
624
- .fields
625
- .iter()
626
- .any(|spec| matches_declared_default(spec, key, value));
627
- if is_default {
628
- if bucket.plugin.remove(key).is_some() {
629
- changed = true;
630
- }
631
- false
632
- } else {
633
- true
634
- }
635
- });
636
- }
637
-
638
- for (k, v) in map {
639
- let prev = bucket.plugin.insert(k, v.clone());
640
- if prev.as_ref() != Some(&v) {
641
- changed = true;
642
- }
643
- }
644
-
645
- changed
646
- },
647
- }
400
+ pub fn is_chart(&self) -> bool {
401
+ self.metadata().name.as_str() != "Datagrid"
648
402
  }
649
403
 
650
- /// Apply a single schema-field update from the plugin-settings UI
651
- /// to the active plugin's bucket. Clear-then-insert semantics
652
- /// mirror [`Self::update_columns_config_field`]. Entries in
653
- /// `update.value` whose value equals the schema default are
654
- /// stripped before applying so default picks reset the key
655
- /// rather than store the default literally.
656
- pub fn update_plugin_config_field(
657
- &self,
658
- view_config: &ViewConfig,
659
- mut update: ColumnConfigFieldUpdate,
660
- ) -> bool {
661
- let Some(n) = self.active_plugin_name() else {
662
- return false;
663
- };
664
-
665
- if let Ok(schema) = self.query_plugin_config_schema(view_config) {
666
- strip_default_values(&schema, &mut update.value);
667
- }
668
-
669
- let mut st = self.borrow_mut();
670
- let bucket = st.plugin_states.entry(n).or_default();
671
- let mut changed = false;
404
+ /// Whether the active plugin opts into per-column style controls.
405
+ pub fn can_render_column_styles(&self) -> bool {
406
+ self.metadata().can_render_column_styles
407
+ }
672
408
 
673
- for k in &update.keys {
674
- if let Some(v) = update.value.get(k) {
675
- let prev = bucket.plugin.insert(k.to_string(), v.clone());
676
- if prev.as_ref() != Some(v) {
677
- changed = true;
678
- }
679
- } else if bucket.plugin.remove(k).is_some() {
680
- changed = true;
681
- }
682
- }
409
+ // ─── Per-column config (active plugin's bucket) ───────────────────
683
410
 
684
- changed
685
- }
411
+ // ─── Plugin-level config (active plugin's bucket) ─────────────────
686
412
 
687
413
  /// Whether the active plugin's render warning is currently armed
688
414
  /// (i.e. an oversized view will be capped). Becomes `false` once
@@ -710,18 +436,31 @@ impl Renderer {
710
436
  self.0.borrow_mut().plugin_store.plugin_configs().clone()
711
437
  }
712
438
 
713
- /// Gets the currently active plugin. Calling this method before a plugin
714
- /// has been selected will cause the default (first) plugin to be
715
- /// selected, and doing so when no plugins have been registered is an
716
- /// error.
717
- pub fn get_active_plugin(&self) -> ApiResult<JsPerspectiveViewerPlugin> {
718
- if self.0.borrow().plugins_idx.is_none() {
719
- let _ = self.apply_pending_plugin()?;
720
- }
721
-
722
- let idx = self.0.borrow().plugins_idx.unwrap_or(0);
723
- let result = self.0.borrow_mut().plugin_store.plugins().get(idx).cloned();
724
- Ok(result.ok_or("No Plugin")?)
439
+ /// The currently-selected plugin, or `None` if none has been selected yet.
440
+ ///
441
+ /// This is a PURE QUERY (command-query separation): unlike
442
+ /// [`Self::ensure_plugin_selected`] it never selects a default plugin and
443
+ /// never triggers the lazy `PluginStore` snapshot before a plugin exists.
444
+ /// Pre-draw / render-path code MUST use this and handle `None` — selecting
445
+ /// a plugin on the first render, before the real plugins register,
446
+ /// permanently pins the per-renderer store to the built-in `Debug`
447
+ /// (init-order race, surfaces on Safari).
448
+ pub fn active_plugin(&self) -> Option<JsPerspectiveViewerPlugin> {
449
+ // Bail on `plugins_idx` BEFORE touching `plugin_store`, so an unselected
450
+ // renderer never snapshots the registry.
451
+ let idx = self.0.borrow().plugins_idx?;
452
+ self.0.borrow_mut().plugin_store.plugins().get(idx).cloned()
453
+ }
454
+
455
+ /// Ensure a plugin is selected — the registry default when nothing has
456
+ /// ever been selected — and return it. The COMMAND half of
457
+ /// [`Self::active_plugin`]: this is the only path that selects a default and
458
+ /// snapshots the store, so it must run only when actually drawing (by which
459
+ /// point real plugins have registered). Errors if no plugins are
460
+ /// registered.
461
+ pub fn ensure_plugin_selected(&self) -> ApiResult<JsPerspectiveViewerPlugin> {
462
+ self.commit_plugin(None)?;
463
+ Ok(self.active_plugin().ok_or("No Plugin")?)
725
464
  }
726
465
 
727
466
  /// Gets a specific `JsPerspectiveViewerPlugin` by name.
@@ -735,25 +474,42 @@ impl Renderer {
735
474
  Ok(result.unwrap())
736
475
  }
737
476
 
477
+ /// Whether the active plugin has completed a draw. A query, not a
478
+ /// command — and an explicit flag rather than the old `is_connected()`
479
+ /// inference, which eager plugin mounting (an element in the DOM before
480
+ /// its first draw) would falsify.
738
481
  pub fn is_plugin_activated(&self) -> ApiResult<bool> {
739
- Ok(self
740
- .get_active_plugin()?
741
- .unchecked_ref::<HtmlElement>()
742
- .is_connected())
482
+ Ok(self.0.has_drawn.get())
483
+ }
484
+
485
+ /// Mount the selected plugin element into the viewer's light DOM without
486
+ /// drawing it (see [`activate::mount_plugin`] — idempotent). Pure query:
487
+ /// a renderer with no selection is a no-op, deferring to the draw path's
488
+ /// `ensure_plugin_selected`. Used to mount eagerly — at panel creation
489
+ /// and at locked-draw start — so a slow first view query never leaves an
490
+ /// empty panel frame. NOTE eager mounting is exactly why
491
+ /// [`Self::is_plugin_activated`] must be an explicit has-drawn flag, not
492
+ /// DOM-connectedness.
493
+ pub fn mount_active_plugin(&self) -> ApiResult<()> {
494
+ if let Some(plugin) = self.active_plugin() {
495
+ let viewer_elem = self.0.borrow().viewer_elem.clone();
496
+ mount_plugin(&viewer_elem, &plugin, self.slot_name().as_deref())?;
497
+ }
498
+
499
+ Ok(())
500
+ }
501
+
502
+ /// Record whether this panel is the active panel (data only — see the
503
+ /// field doc; the CSS class lands at the next locked plugin dispatch).
504
+ pub fn set_active_flag(&self, is_active: bool) {
505
+ self.0.is_active_panel.set(is_active);
743
506
  }
744
507
 
745
- pub async fn restyle_all(&self, view: &perspective_client::View) -> ApiResult<JsValue> {
746
- let plugin = self.get_active_plugin()?;
747
- let meta = self.metadata();
748
- plugin.restyle();
749
- let mut limits =
750
- get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
751
- limits.is_update = false;
752
- plugin
753
- .draw(view.clone().into(), limits.max_cols, limits.max_rows, false)
754
- .await?;
755
-
756
- Ok(JsValue::UNDEFINED)
508
+ /// Record whether this panel is the workspace's only panel (data only —
509
+ /// see the field doc; the CSS class lands at the next locked plugin
510
+ /// dispatch).
511
+ pub fn set_solo_flag(&self, is_solo: bool) {
512
+ self.0.is_solo_panel.set(is_solo);
757
513
  }
758
514
 
759
515
  pub fn set_throttle(&self, val: Option<f64>) {
@@ -777,10 +533,22 @@ impl Renderer {
777
533
  self.0.render_warning.set(false);
778
534
  }
779
535
 
780
- pub fn get_next_plugin_metadata(
536
+ /// Resolve a [`PluginUpdate`] against the current selection. Returns the
537
+ /// target plugin's index + static config when the update names a plugin
538
+ /// (or the registry default, for `SetDefault`) that differs from the
539
+ /// current selection; `None` when the update is `Missing`, names an
540
+ /// unknown plugin, or resolves to the already-active one.
541
+ ///
542
+ /// PURE query — nothing is staged on the `Renderer`. Thread the returned
543
+ /// index *into* a locked draw task and commit it there with
544
+ /// [`Self::commit_plugin`]: plugin-swap intent must never exist outside a
545
+ /// running draw transaction, or an unrelated draw that wins the lock first
546
+ /// (e.g. a `table_updated` redraw during a `restore()`) could observe or
547
+ /// commit a half-applied swap.
548
+ pub fn resolve_plugin_update(
781
549
  &self,
782
550
  update: &PluginUpdate,
783
- ) -> Option<Rc<PluginStaticConfig>> {
551
+ ) -> Option<(usize, Rc<PluginStaticConfig>)> {
784
552
  let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
785
553
  let name = match update {
786
554
  PluginUpdate::Missing => return None,
@@ -795,47 +563,43 @@ impl Renderer {
795
563
  );
796
564
 
797
565
  if changed {
798
- self.borrow_mut().pending_plugin = Some(idx);
799
- self.0
566
+ let config = self
567
+ .0
800
568
  .borrow_mut()
801
569
  .plugin_store
802
570
  .plugin_configs()
803
571
  .get(idx)
804
- .cloned()
572
+ .cloned()?;
573
+ Some((idx, config))
805
574
  } else {
806
575
  None
807
576
  }
808
577
  }
809
578
 
810
- pub fn apply_pending_plugin(&self) -> ApiResult<bool> {
811
- let xxx = self.borrow_mut().pending_plugin.take();
812
- if let Some(idx) = xxx {
813
- let changed = !matches!(
814
- self.0.borrow().plugins_idx,
815
- Some(selected_idx) if selected_idx == idx
816
- );
817
-
818
- if changed {
819
- self.commit_plugin_idx(idx)?;
820
- }
821
-
822
- Ok(changed)
823
- } else {
824
- if self.0.borrow().plugins_idx.is_none() {
825
- self.set_plugin(Some(&PLUGIN_REGISTRY.default_plugin_name()))?;
826
- }
827
-
828
- Ok(false)
829
- }
830
- }
579
+ /// Commit a plugin selection previously resolved by
580
+ /// [`Self::resolve_plugin_update`]. COMMAND — call only from inside a
581
+ /// locked draw task, so the swap lands atomically with the view rebuild
582
+ /// and draw it belongs to. `None` ensures *some* plugin is selected (the
583
+ /// registry default) without changing an existing selection. Returns
584
+ /// whether the active plugin changed (`None`'s default-selection case
585
+ /// reports `false`, preserving first-selection semantics for the
586
+ /// swap-restore pass in the draw tasks).
587
+ pub fn commit_plugin(&self, idx: Option<usize>) -> ApiResult<bool> {
588
+ let idx = match idx {
589
+ Some(idx) => idx,
590
+ None => {
591
+ if self.0.borrow().plugins_idx.is_none() {
592
+ let name = PLUGIN_REGISTRY.default_plugin_name();
593
+ let idx = self
594
+ .find_plugin_idx(&name)
595
+ .ok_or_else(|| JsValue::from(format!("Unknown plugin '{name}'")))?;
596
+
597
+ self.commit_plugin_idx(idx)?;
598
+ }
831
599
 
832
- fn set_plugin(&self, name: Option<&str>) -> ApiResult<bool> {
833
- self.borrow_mut().pending_plugin = None;
834
- let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
835
- let name = name.unwrap_or(default_plugin_name.as_str());
836
- let idx = self
837
- .find_plugin_idx(name)
838
- .ok_or_else(|| JsValue::from(format!("Unknown plugin '{name}'")))?;
600
+ return Ok(false);
601
+ },
602
+ };
839
603
 
840
604
  let changed = !matches!(
841
605
  self.0.borrow().plugins_idx,
@@ -849,11 +613,16 @@ impl Renderer {
849
613
  Ok(changed)
850
614
  }
851
615
 
852
- /// Shared tail of `apply_pending_plugin` / `set_plugin`: switch the
616
+ /// Shared tail of [`Self::commit_plugin`]: switch the
853
617
  /// active plugin to `idx`, swap in its cached `PluginStaticConfig`,
854
618
  /// reset the per-plugin render-warning flag, and fire
855
619
  /// `plugin_changed`.
856
620
  fn commit_plugin_idx(&self, idx: usize) -> ApiResult<()> {
621
+ // The newly-selected plugin element has not drawn (a swap keeps the
622
+ // OLD plugin mounted until the new one's draw lands) — and has
623
+ // captured no CSS.
624
+ self.0.has_drawn.set(false);
625
+ self.0.captured_theme.borrow_mut().take();
857
626
  self.borrow_mut().plugins_idx = Some(idx);
858
627
  let config = self
859
628
  .0
@@ -866,7 +635,10 @@ impl Renderer {
866
635
 
867
636
  self.borrow_mut().metadata = config.clone();
868
637
  self.0.render_warning.set(true);
869
- let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
638
+ // `commit_plugin_idx` is called *by* the selection path, so it must use
639
+ // the pure query (never `ensure_plugin_selected`, which would recurse
640
+ // through `commit_plugin`). `plugins_idx` was just set above.
641
+ let plugin: JsPerspectiveViewerPlugin = self.active_plugin().ok_or("No Plugin")?;
870
642
 
871
643
  // Push the newly-activated plugin's stored bucket through
872
644
  // `plugin.restore` so the swap immediately reflects any
@@ -886,192 +658,6 @@ impl Renderer {
886
658
  Ok(())
887
659
  }
888
660
 
889
- pub async fn with_lock<T>(self, task: impl Future<Output = ApiResult<T>>) -> ApiResult<T> {
890
- let draw_mutex = self.draw_lock();
891
- draw_mutex.lock(task).await
892
- }
893
-
894
- pub async fn resize(&self) -> ApiResult<()> {
895
- let draw_mutex = self.draw_lock();
896
- let timer = self.render_timer();
897
- draw_mutex
898
- .debounce(async {
899
- set_timeout(timer.get_throttle()).await?;
900
- let jsplugin = self.get_active_plugin()?;
901
- jsplugin.resize().await?;
902
- Ok(())
903
- })
904
- .await
905
- }
906
-
907
- pub async fn resize_with_dimensions(&self, width: f64, height: f64) -> ApiResult<()> {
908
- let draw_mutex = self.draw_lock();
909
- let timer = self.render_timer();
910
- draw_mutex
911
- .debounce(async {
912
- set_timeout(timer.get_throttle()).await?;
913
- let plugin = self.get_active_plugin()?;
914
- let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
915
- let rect = main_panel.get_bounding_client_rect();
916
- if (height - rect.height()).abs() > 0.5 || (width - rect.width()).abs() > 0.5 {
917
- let new_width = format!("{}px", width);
918
- let new_height = format!("{}px", height);
919
- main_panel.style().set_property("width", &new_width)?;
920
- main_panel.style().set_property("height", &new_height)?;
921
- let result = plugin.resize().await;
922
- main_panel.style().set_property("width", "")?;
923
- main_panel.style().set_property("height", "")?;
924
- result?;
925
- }
926
-
927
- Ok(())
928
- })
929
- .await
930
- }
931
-
932
- /// This will take a future which _should_ create a new view and then will
933
- /// draw it. As the `session` closure is asynchronous, it can be cancelled
934
- /// by returning `None`.
935
- pub async fn draw(
936
- &self,
937
- session: impl Future<Output = ApiResult<Option<View>>>,
938
- ) -> ApiResult<()> {
939
- self.draw_plugin(session, false).await
940
- }
941
-
942
- /// This will update an already existing view
943
- pub async fn update(&self, session: Option<View>) -> ApiResult<()> {
944
- self.draw_plugin(async { Ok(session) }, true).await
945
- }
946
-
947
- async fn draw_plugin(
948
- &self,
949
- session: impl Future<Output = ApiResult<Option<View>>>,
950
- is_update: bool,
951
- ) -> ApiResult<()> {
952
- let timer = self.render_timer();
953
- let task = async move {
954
- if is_update {
955
- set_timeout(timer.get_throttle()).await?;
956
- }
957
-
958
- if let Some(view) = session.await? {
959
- timer.capture_time(self.draw_view(&view, is_update)).await
960
- } else {
961
- tracing::debug!("Render skipped, no `View` attached");
962
- Ok(())
963
- }
964
- };
965
-
966
- let draw_mutex = self.draw_lock();
967
- if is_update {
968
- draw_mutex.debounce(task).await
969
- } else {
970
- draw_mutex.lock(task).await
971
- }
972
- }
973
-
974
- async fn draw_view(&self, view: &perspective_client::View, is_update: bool) -> ApiResult<()> {
975
- let plugin = self.get_active_plugin()?;
976
- let meta = self.metadata();
977
- let mut limits =
978
- get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
979
- limits.is_update = is_update;
980
- if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() {
981
- cb.emit(limits);
982
- }
983
-
984
- let viewer_elem = &self.0.borrow().viewer_elem.clone();
985
- let result = if is_update {
986
- let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false);
987
- activate_plugin(viewer_elem, &plugin, task).await
988
- } else {
989
- let task = plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false);
990
- activate_plugin(viewer_elem, &plugin, task).await
991
- };
992
-
993
- if let Err(error) = result.ignore_view_delete() {
994
- tracing::warn!("{}", error);
995
- }
996
-
997
- remove_inactive_plugin(
998
- viewer_elem,
999
- &plugin,
1000
- self.plugin_data.borrow_mut().plugin_store.plugins(),
1001
- )
1002
- }
1003
-
1004
- /// Decide whether to draw plugin or self first based on whether the panel
1005
- /// is opening or closing, then draw with a timeout. If the timeout
1006
- /// triggers, draw self and resolve `on_toggle` but still await the
1007
- /// completion of the draw task.
1008
- pub async fn presize(
1009
- &self,
1010
- open: bool,
1011
- panel_task: impl Future<Output = ApiResult<()>>,
1012
- ) -> ApiResult<JsValue> {
1013
- let render_task = self.resize_with_timeout(open);
1014
- let result = if open {
1015
- panel_task.await?;
1016
- render_task.await
1017
- } else {
1018
- let result = render_task.await;
1019
- panel_task.await?;
1020
- result
1021
- };
1022
-
1023
- match result {
1024
- Ok(x) => x,
1025
- Err(cont) => {
1026
- tracing::warn!("Presize took longer than {}ms", PRESIZE_TIMEOUT);
1027
- cont.await.unwrap()
1028
- },
1029
- }
1030
- }
1031
-
1032
- /// Lock on `resize()` task, in parallel with a timeout. In the return
1033
- /// type, `Result::Err` contains the continuation task, which must be
1034
- /// awaited lest the plugin draw itself never trigger.
1035
- async fn resize_with_timeout(&self, open: bool) -> Result<TaskResult, TimeoutTask<'_>> {
1036
- let task = async move {
1037
- if open {
1038
- self.get_active_plugin()?.resize().await
1039
- } else {
1040
- self.resize_with_explicit_dimensions().await
1041
- }
1042
- };
1043
-
1044
- let draw_lock = self.draw_lock();
1045
- let tasks: [TimeoutTask<'_>; 2] = [
1046
- Box::pin(async move { Some(draw_lock.lock(task).await) }),
1047
- Box::pin(async {
1048
- set_timeout(PRESIZE_TIMEOUT).await.unwrap();
1049
- None
1050
- }),
1051
- ];
1052
-
1053
- let (x, _, y) = select_all(tasks).await;
1054
- x.ok_or_else(|| y.into_iter().next().unwrap())
1055
- }
1056
-
1057
- /// Resize the `<div>` offscreen, then resize the plugin
1058
- async fn resize_with_explicit_dimensions(&self) -> TaskResult {
1059
- let plugin = self.get_active_plugin()?;
1060
- let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
1061
- let new_width = format!("{}px", self.0.borrow().viewer_elem.client_width());
1062
- let new_height = format!("{}px", self.0.borrow().viewer_elem.client_height());
1063
- main_panel.style().set_property("width", &new_width)?;
1064
- main_panel.style().set_property("height", &new_height)?;
1065
- let result = plugin.resize().await;
1066
- main_panel.style().set_property("width", "")?;
1067
- main_panel.style().set_property("height", "")?;
1068
- result
1069
- }
1070
-
1071
- fn draw_lock(&self) -> DebounceMutex {
1072
- self.draw_lock.clone()
1073
- }
1074
-
1075
661
  pub fn render_timer(&self) -> MovingWindowRenderTimer {
1076
662
  self.0.borrow().timer.clone()
1077
663
  }
@@ -1080,10 +666,6 @@ impl Renderer {
1080
666
  let short_name = make_short_name(name);
1081
667
  let mut borrowed = self.0.borrow_mut();
1082
668
  let configs = borrowed.plugin_store.plugin_configs();
1083
- // Prefer an exact (normalised) match so e.g. `"Y Line"` doesn't
1084
- // substring-resolve to `"X/Y Line"` just because it was registered
1085
- // first. Falls back to `contains` so short/abbreviated names
1086
- // (`restore({ plugin: "scat" })` → `"scatter"`) still work.
1087
669
  let short_names: Vec<String> = configs.iter().map(|c| make_short_name(&c.name)).collect();
1088
670
  if let Some(i) = short_names.iter().position(|n| n == &short_name) {
1089
671
  return Some(i);
@@ -1107,14 +689,7 @@ impl Renderer {
1107
689
  /// suitable for passing as a Yew prop. Called by the root component
1108
690
  /// whenever a renderer-related PubSub event fires.
1109
691
  pub fn to_props(&self, render_limits: Option<RenderLimits>) -> RendererProps {
1110
- // Guard: don't touch the PluginStore if no plugin has been explicitly
1111
- // selected yet. Calling `get_active_plugin()` or `get_all_plugins()`
1112
- // triggers `PluginStore::init_lazy()`, which snapshots the
1113
- // PLUGIN_REGISTRY. If this happens during component `create()` —
1114
- // before JavaScript has called `registerPlugin()` — the cache will
1115
- // only contain the default Debug plugin and custom plugins registered
1116
- // later will never be found.
1117
- let has_plugin = self.0.borrow().plugins_idx.is_some();
692
+ let has_plugin = self.active_plugin().is_some();
1118
693
  if has_plugin {
1119
694
  let config = self.metadata();
1120
695
  let plugin_name = Some(config.name.clone());
@@ -1125,8 +700,8 @@ impl Renderer {
1125
700
  .map(|c| c.name.clone())
1126
701
  .collect::<Vec<_>>()
1127
702
  .into();
1128
- let plugin_config = PtrEqRc::new(self.get_plugin_config());
1129
703
 
704
+ let plugin_config = PtrEqRc::new(self.get_plugin_config());
1130
705
  RendererProps {
1131
706
  plugin_name,
1132
707
  config,
@@ -1147,65 +722,3 @@ impl Renderer {
1147
722
  }
1148
723
  }
1149
724
  }
1150
-
1151
- /// Drop entries from `map` whose value matches the schema-declared
1152
- /// default for that key. Used by both the plugin-config and
1153
- /// columns-config write paths to converge buckets to the
1154
- /// "empty ⇒ reads-default" invariant. For `ControlSpec::Number`
1155
- /// entries marked `include: Some(true)`,
1156
- /// [`matches_declared_default`] short-circuits so the value survives
1157
- /// (used when the declared default is data-dependent and unreliable —
1158
- /// e.g. Datagrid's `fg_gradient`, whose default is the column's
1159
- /// `abs_max`).
1160
- fn strip_default_values(
1161
- schema: &ColumnConfigSchema,
1162
- map: &mut serde_json::Map<String, serde_json::Value>,
1163
- ) {
1164
- map.retain(|key, value| {
1165
- !schema
1166
- .fields
1167
- .iter()
1168
- .any(|spec| matches_declared_default(spec, key, value))
1169
- });
1170
- }
1171
-
1172
- /// Does `value` for `key` match the `default` declared by `spec`?
1173
- /// Composite variants (`NumberSeriesStyle`, `DatetimeFormat`, etc.) own
1174
- /// nested defaults that don't have a single comparable scalar — the
1175
- /// widget is responsible for emitting empty values when the user
1176
- /// resets composite controls, so this helper returns `false` for them.
1177
- fn matches_declared_default(spec: &ControlSpec, key: &str, value: &Value) -> bool {
1178
- match spec {
1179
- ControlSpec::Enum {
1180
- key: k, default, ..
1181
- } if k == key => value.as_str() == Some(default.as_str()),
1182
- ControlSpec::Bool {
1183
- key: k, default, ..
1184
- } if k == key => value.as_bool() == Some(*default),
1185
- ControlSpec::Number {
1186
- key: k,
1187
- include: Some(true),
1188
- ..
1189
- } if k == key => false,
1190
- ControlSpec::Number {
1191
- key: k, default, ..
1192
- } if k == key => value.as_f64() == Some(*default),
1193
- ControlSpec::String {
1194
- key: k, default, ..
1195
- } if k == key => value.as_str() == Some(default.as_str()),
1196
- ControlSpec::Color {
1197
- key: k, default, ..
1198
- } if k == key => value.as_str() == Some(default.as_str()),
1199
- ControlSpec::ColorRange {
1200
- key_pos,
1201
- default_pos,
1202
- ..
1203
- } if key_pos == key => value.as_str() == Some(default_pos.as_str()),
1204
- ControlSpec::ColorRange {
1205
- key_neg,
1206
- default_neg,
1207
- ..
1208
- } if key_neg == key => value.as_str() == Some(default_neg.as_str()),
1209
- _ => false,
1210
- }
1211
- }