@perspective-dev/viewer 4.5.2 → 5.1.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 (227) 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/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/extensions.d.ts +22 -3
  24. package/dist/esm/perspective-viewer.d.ts +9 -0
  25. package/dist/esm/perspective-viewer.inline.js +20 -1
  26. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  27. package/dist/esm/perspective-viewer.js +20 -1
  28. package/dist/esm/perspective-viewer.js.map +4 -4
  29. package/dist/esm/plugin.d.ts +71 -5
  30. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  31. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  32. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  33. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  34. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  35. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  36. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  37. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  38. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +41 -0
  39. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  40. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  41. package/dist/esm/ts-rs/ViewerConfig.d.ts +8 -2
  42. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +9 -0
  43. package/dist/esm/ts-rs/WindowAggregate.d.ts +1 -0
  44. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  45. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  46. package/dist/esm/ts-rs/WindowSpec.d.ts +46 -0
  47. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  48. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  49. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  50. package/dist/wasm/perspective-viewer.d.ts +299 -78
  51. package/dist/wasm/perspective-viewer.js +544 -186
  52. package/dist/wasm/perspective-viewer.wasm +0 -0
  53. package/dist/wasm/perspective-viewer.wasm.d.ts +34 -26
  54. package/dist/wasm/snippets/perspective-viewer-8a8c7d5d696b43ae/inline0.js +6 -0
  55. package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline3.js → perspective-viewer-8a8c7d5d696b43ae/inline4.js} +5 -0
  56. package/package.json +3 -2
  57. package/src/css/_context_menu.css +14 -0
  58. package/src/css/column-selector.css +1 -0
  59. package/src/css/column-settings-panel.css +68 -3
  60. package/src/css/column-style.css +4 -3
  61. package/src/css/config-selector.css +22 -1
  62. package/src/css/containers/context-menu.css +107 -0
  63. package/src/css/containers/tabs.css +2 -2
  64. package/src/css/panel-tab.css +193 -0
  65. package/src/css/plugin-selector.css +7 -10
  66. package/src/css/status-bar.css +75 -81
  67. package/src/css/viewer.css +188 -70
  68. package/src/rust/components/column_dropdown.rs +40 -30
  69. package/src/rust/components/column_selector/active_column.rs +28 -45
  70. package/src/rust/components/column_selector/aggregate_selector.rs +4 -7
  71. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  72. package/src/rust/components/column_selector/config_selector.rs +40 -82
  73. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  74. package/src/rust/components/column_selector/filter_column.rs +5 -15
  75. package/src/rust/components/column_selector/inactive_column.rs +11 -9
  76. package/src/rust/components/column_selector/sort_column.rs +3 -6
  77. package/src/rust/components/column_selector.rs +25 -14
  78. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  79. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  80. package/src/rust/components/column_settings_sidebar.rs +163 -16
  81. package/src/rust/components/containers/dragdrop_list.rs +73 -3
  82. package/src/rust/components/containers/sidebar.rs +99 -22
  83. package/src/rust/components/containers/split_panel.rs +181 -62
  84. package/src/rust/components/context_menu.rs +130 -0
  85. package/src/rust/components/editable_header.rs +2 -0
  86. package/src/rust/components/form/debug.rs +3 -0
  87. package/src/rust/components/global_filter_bar.rs +76 -0
  88. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  89. package/src/rust/components/main_panel/msg.rs +62 -0
  90. package/src/rust/components/main_panel/presize.rs +220 -0
  91. package/src/rust/components/main_panel/reconcile.rs +224 -0
  92. package/src/rust/components/main_panel/render.rs +385 -0
  93. package/src/rust/components/main_panel/update.rs +184 -0
  94. package/src/rust/components/main_panel.rs +316 -71
  95. package/src/rust/components/mod.rs +5 -0
  96. package/src/rust/components/panel_menu.rs +493 -0
  97. package/src/rust/components/panel_tab.rs +510 -0
  98. package/src/rust/components/plugin_tab.rs +1 -1
  99. package/src/rust/components/portal.rs +7 -0
  100. package/src/rust/components/settings_panel.rs +13 -5
  101. package/src/rust/components/status_bar.rs +110 -178
  102. package/src/rust/components/status_indicator.rs +6 -9
  103. package/src/rust/components/style/mod.rs +5 -0
  104. package/src/rust/components/style/style_provider.rs +2 -0
  105. package/src/rust/components/viewer/filters.rs +133 -0
  106. package/src/rust/components/viewer/msg.rs +182 -0
  107. package/src/rust/components/viewer/panels.rs +326 -0
  108. package/src/rust/components/viewer/render.rs +255 -0
  109. package/src/rust/components/viewer/settings.rs +434 -0
  110. package/src/rust/components/viewer/snapshots.rs +134 -0
  111. package/src/rust/components/viewer/wiring.rs +505 -0
  112. package/src/rust/components/viewer.rs +245 -756
  113. package/src/rust/components/window_editor.rs +1013 -0
  114. package/src/rust/config/export_method.rs +1 -1
  115. package/src/rust/config/mod.rs +4 -0
  116. package/src/rust/config/options.rs +71 -0
  117. package/src/rust/config/viewer_config.rs +24 -2
  118. package/src/rust/config/workspace_config.rs +92 -0
  119. package/src/rust/custom_elements/copy_dropdown.rs +3 -1
  120. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  121. package/src/rust/custom_elements/export_dropdown.rs +3 -1
  122. package/src/rust/custom_elements/viewer.rs +1015 -286
  123. package/src/rust/custom_events.rs +212 -61
  124. package/src/rust/js/mod.rs +2 -0
  125. package/src/rust/js/plugin.rs +297 -10
  126. package/src/rust/js/regular_layout.rs +258 -0
  127. package/src/rust/lib.rs +24 -2
  128. package/src/rust/presentation/column_locator.rs +16 -1
  129. package/src/rust/presentation.rs +66 -33
  130. package/src/rust/queries/columns_iter_set.rs +7 -1
  131. package/src/rust/queries/get_viewer_config.rs +29 -11
  132. package/src/rust/queries/plugin_column_styles.rs +2 -2
  133. package/src/rust/renderer/activate.rs +41 -14
  134. package/src/rust/renderer/dispatch.rs +463 -0
  135. package/src/rust/renderer/plugin_config.rs +495 -0
  136. package/src/rust/renderer/registry.rs +18 -6
  137. package/src/rust/renderer/render_timer.rs +29 -10
  138. package/src/rust/renderer.rs +384 -838
  139. package/src/rust/session/column_defaults_update.rs +26 -8
  140. package/src/rust/session/drag_drop_update.rs +18 -0
  141. package/src/rust/session/metadata.rs +66 -9
  142. package/src/rust/session/replace_expression_update.rs +1 -0
  143. package/src/rust/session/view_subscription.rs +30 -15
  144. package/src/rust/session.rs +755 -264
  145. package/src/rust/tasks/apply_global_filters.rs +89 -0
  146. package/src/rust/tasks/auto_pause.rs +171 -0
  147. package/src/rust/tasks/copy_export.rs +29 -19
  148. package/src/rust/tasks/create_panel.rs +243 -0
  149. package/src/rust/tasks/dismiss_render_warning.rs +3 -1
  150. package/src/rust/tasks/edit_expression.rs +4 -4
  151. package/src/rust/tasks/edit_window.rs +155 -0
  152. package/src/rust/tasks/eject.rs +64 -23
  153. package/src/rust/tasks/mod.rs +30 -4
  154. package/src/rust/tasks/pipeline.rs +411 -0
  155. package/src/rust/tasks/presize_panels.rs +324 -0
  156. package/src/rust/tasks/reset_all.rs +22 -12
  157. package/src/rust/tasks/resize_observer.rs +124 -87
  158. package/src/rust/tasks/restore_and_render.rs +80 -86
  159. package/src/rust/tasks/restore_panel.rs +145 -0
  160. package/src/rust/tasks/send_column_config.rs +5 -2
  161. package/src/rust/tasks/send_plugin_config.rs +5 -2
  162. package/src/rust/tasks/set_edit_mode.rs +59 -0
  163. package/src/rust/tasks/sync_update_panels.rs +152 -0
  164. package/src/rust/tasks/table_lifecycle.rs +158 -0
  165. package/src/rust/tasks/update_theme.rs +56 -12
  166. package/src/rust/utils/browser/dragdrop.rs +12 -0
  167. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  168. package/src/rust/utils/completion.rs +56 -0
  169. package/src/rust/utils/custom_element.rs +5 -0
  170. package/src/rust/utils/debounce.rs +189 -17
  171. package/src/rust/utils/mod.rs +6 -0
  172. package/src/rust/utils/pending_effects.rs +62 -0
  173. package/src/rust/utils/spawn.rs +33 -0
  174. package/src/rust/utils/weak_scope.rs +0 -10
  175. package/src/rust/workspace.rs +846 -0
  176. package/src/svg/active-indicator.svg +3 -0
  177. package/src/svg/pin-icon.svg +5 -0
  178. package/src/themes/botanical.css +5 -2
  179. package/src/themes/defaults.css +54 -2
  180. package/src/themes/dracula.css +6 -3
  181. package/src/themes/gruvbox-dark.css +5 -2
  182. package/src/themes/gruvbox.css +5 -2
  183. package/src/themes/icons.css +5 -2
  184. package/src/themes/intl/de.css +7 -0
  185. package/src/themes/intl/es.css +7 -0
  186. package/src/themes/intl/fr.css +7 -0
  187. package/src/themes/intl/ja.css +7 -0
  188. package/src/themes/intl/pt.css +7 -0
  189. package/src/themes/intl/zh.css +7 -0
  190. package/src/themes/intl.css +10 -1
  191. package/src/themes/monokai.css +5 -2
  192. package/src/themes/phosphor.css +5 -2
  193. package/src/themes/pro-dark.css +5 -2
  194. package/src/themes/pro.css +2 -1
  195. package/src/themes/solarized-dark.css +5 -2
  196. package/src/themes/solarized.css +5 -2
  197. package/src/themes/vaporwave.css +5 -2
  198. package/src/ts/bootstrap.ts +8 -7
  199. package/src/ts/extensions.ts +30 -2
  200. package/src/ts/perspective-viewer.ts +10 -0
  201. package/src/ts/plugin.ts +72 -5
  202. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  203. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  204. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  205. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  206. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  207. package/src/ts/ts-rs/Layout.ts +9 -0
  208. package/src/ts/ts-rs/Orientation.ts +6 -0
  209. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  210. package/src/ts/ts-rs/PanelViewerConfig.ts +16 -0
  211. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  212. package/src/ts/ts-rs/TabLayout.ts +11 -0
  213. package/src/ts/ts-rs/ViewerConfig.ts +7 -2
  214. package/src/ts/ts-rs/ViewerConfigUpdate.ts +9 -0
  215. package/src/ts/ts-rs/WindowAggregate.ts +3 -0
  216. package/src/ts/ts-rs/WindowSort.ts +9 -0
  217. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  218. package/src/ts/ts-rs/WindowSpec.ts +46 -0
  219. package/src/ts/ts-rs/Windows.ts +10 -0
  220. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  221. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  222. package/src/rust/tasks/intersection_observer.rs +0 -98
  223. package/src/rust/tasks/update_and_render.rs +0 -110
  224. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline0.js → perspective-viewer-8a8c7d5d696b43ae/inline1.js} +0 -0
  225. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline1.js → perspective-viewer-8a8c7d5d696b43ae/inline2.js} +0 -0
  226. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline2.js → perspective-viewer-8a8c7d5d696b43ae/inline3.js} +0 -0
  227. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline4.js → perspective-viewer-8a8c7d5d696b43ae/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,59 @@ 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
+ /// One queued geometry task for the [`RendererData::geometry_slot`] runner.
57
+ /// `Measure` re-measures the plugin's live box (reactive resize); `Presize`
58
+ /// renders an anticipated box ahead of a layout commit. A later command
59
+ /// subsumes an earlier unconsumed one: `Measure` reflects whatever geometry
60
+ /// has committed by run time, and `Presize` anticipates the NEXT commit —
61
+ /// callers issue them in commit order.
62
+ #[derive(Clone, Copy)]
63
+ pub(crate) enum GeometryCmd {
64
+ Measure,
65
+ Presize {
66
+ translate: Option<(f64, f64)>,
67
+ width: f64,
68
+ height: f64,
69
+ },
70
+ }
71
+
72
+ /// Everything a plugin may read back during its render, all belonging to
73
+ /// one snapshot (invariant I5). Built once per bound `View` by the render
74
+ /// pipeline and CACHED on the `Renderer`; pinning it for a run is an `Rc`
75
+ /// clone (cheap enough for resize-tick-rate runs). While pinned, the
76
+ /// per-panel element getters (`getViewConfigPanel`, `getTablePanel`,
77
+ /// `getClientPanel`, `getEditPortPanel`) answer from it — the plugin ABI is
78
+ /// inside the snapshot boundary.
79
+ pub struct RenderContext {
80
+ pub view_config: Rc<perspective_client::config::ViewConfig>,
81
+ pub view: perspective_client::View,
82
+ pub table: perspective_client::Table,
83
+ pub client: perspective_client::Client,
84
+ pub edit_port: Option<f64>,
85
+ pub theme: Option<String>,
86
+ }
87
+
88
+ /// RAII pin for a [`RenderContext`] — cleared on drop, INCLUDING error
89
+ /// exits, so a failed run can't leak a stale context.
90
+ pub struct ContextPin(Renderer);
91
+
92
+ impl Drop for ContextPin {
93
+ fn drop(&mut self) {
94
+ self.0.0.active_context.borrow_mut().take();
95
+ }
72
96
  }
73
97
 
74
98
  /// Immutable state
@@ -92,8 +116,98 @@ pub struct RendererData {
92
116
  /// dismissable.
93
117
  pub render_warning: Cell<bool>,
94
118
 
119
+ /// Count of in-flight [`Renderer::resize_with_dimensions`] /
120
+ /// [`Renderer::presize_with_box`] presize calls.
121
+ presize_pending: Cell<u32>,
122
+
123
+ /// Debounce slot for geometry tasks (`resize` / presize), separate from
124
+ /// the default slot data-update redraws coalesce on. Invariant: a
125
+ /// geometry request must never resolve via a parked DATA task — a
126
+ /// pending update's redraw renders at the worker's current dimensions
127
+ /// and cannot subsume a size change (the drag-during-updates permanent
128
+ /// skew).
129
+ geometry_slot: DebounceSlot,
130
+
131
+ /// Latest-wins parameter cell for the geometry slot, consumed by the
132
+ /// runner AT RUN TIME under the lock. Parameters live here rather than
133
+ /// in runner captures so a parked runner can never act on a stale
134
+ /// target when later calls coalesced onto it.
135
+ geometry_cmd: Cell<Option<GeometryCmd>>,
136
+
95
137
  /// Fires after every draw/update with the computed render limits.
96
138
  pub on_render_limits_changed: RefCell<Option<Callback<RenderLimits>>>,
139
+
140
+ /// The layout slot name (panel id) under which this renderer mounts its
141
+ /// plugin in the viewer's light DOM, so multiple panels' plugins can
142
+ /// coexist there.
143
+ slot_name: RefCell<Option<String>>,
144
+
145
+ /// This panel's selected theme name (per-panel theming), or `None` to
146
+ /// inherit the element-level (active) theme.
147
+ theme: RefCell<Option<String>>,
148
+
149
+ /// The registry default theme name (first registered), cached here so
150
+ /// LOCKED draw paths can resolve this panel's EFFECTIVE theme
151
+ /// synchronously (`Presentation::get_default_theme_name` is async — it
152
+ /// awaits theme discovery). Seeded by every content-load path before its
153
+ /// first locked draw (`restore_and_render`, `load()`, the
154
+ /// resize-observer's deferred first render) and kept fresh by the root's
155
+ /// `UpdatePresentation` default-theme fan-out and `resetThemes`.
156
+ default_theme: RefCell<Option<String>>,
157
+
158
+ /// Whether the active plugin has completed a draw. An EXPLICIT flag —
159
+ /// not inferred from DOM connectedness — because plugin elements may be
160
+ /// mounted eagerly (at panel creation / draw start, before the view
161
+ /// query resolves), so "in the DOM" no longer implies "has rendered".
162
+ /// Set by a successful `draw_view`; cleared on plugin swap
163
+ /// (`commit_plugin_idx`), `dispose` and `delete`.
164
+ has_drawn: Cell<bool>,
165
+
166
+ /// The effective theme stamped at the active plugin's last `--psp-*`
167
+ /// CSS CAPTURE — its first paint ([`Renderer::draw_view`]) or last
168
+ /// [`Renderer::restyle_all`]. `None` = no capture has happened. The
169
+ /// state behind [`Renderer::needs_restyle`]: restyle necessity is a
170
+ /// comparison against what the plugin actually captured, never
171
+ /// inferred from which call site mutated a theme record (the
172
+ /// raycasting boot double render — see
173
+ /// `PLUGIN_DRAW_INVARIANT_PLAN.md`, captured-theme revision). Same
174
+ /// lifecycle as [`Self::has_drawn`]: cleared on plugin swap,
175
+ /// `dispose` and `delete`; written only inside locked dispatches.
176
+ captured_theme: RefCell<Option<Option<String>>>,
177
+
178
+ /// The [`RenderContext`] of the currently-bound `View` (built at bind
179
+ /// time by the pipeline). Cleared on `dispose`/`delete`.
180
+ cached_context: RefCell<Option<Rc<RenderContext>>>,
181
+
182
+ /// The [`RenderContext`] pinned by the run currently holding the draw
183
+ /// lock, if any (invariant I5). Managed by [`ContextPin`].
184
+ active_context: RefCell<Option<Rc<RenderContext>>>,
185
+
186
+ /// Whether this renderer's panel is the workspace's ACTIVE panel. Pure
187
+ /// DATA, written synchronously by `Workspace::set_active`/`insert_panel`;
188
+ /// the `active` CSS class it drives is applied ONLY inside locked plugin
189
+ /// dispatches ([`Renderer::stamp_active`], "stamp before draw" — like the
190
+ /// theme), so activation-dependent chrome and the DOM it styles always
191
+ /// land in one paint commit.
192
+ is_active_panel: Cell<bool>,
193
+
194
+ /// Whether this renderer's panel is the workspace's ONLY panel. Pure DATA
195
+ /// like [`Self::is_active_panel`], written synchronously by the
196
+ /// `Workspace` panel-count mutation sites (`new`/`insert_panel`/
197
+ /// `remove_panel`); the `single`/`multi` CSS classes it drives are
198
+ /// applied ONLY inside locked plugin dispatches
199
+ /// ([`Renderer::stamp_active`]), for the same one-paint-commit reason.
200
+ is_solo_panel: Cell<bool>,
201
+
202
+ /// A `table_updated` redraw was DROPPED because this panel's plugin was
203
+ /// hidden (an unslotted tab-stack panel — see
204
+ /// `tasks::create_panel::wire_panel_render_sub`), so the plugin's
205
+ /// retained frame no longer reflects the bound `View`'s data. The
206
+ /// activation nudge consumes it to dispatch a full `plugin.update`
207
+ /// instead of the usual chrome-only `resize` repaint. Cleared at every
208
+ /// `draw_view` dispatch — set BEFORE the plugin's data fetch, so an
209
+ /// update landing mid-draw re-marks it and can never be lost.
210
+ data_stale: Cell<bool>,
97
211
  }
98
212
 
99
213
  /// Mutable state
@@ -104,9 +218,6 @@ pub struct RendererMutData {
104
218
  plugins_idx: Option<usize>,
105
219
  timer: MovingWindowRenderTimer,
106
220
  selection: Option<ViewWindow>,
107
- pending_plugin: Option<usize>,
108
-
109
- /// Per-plugin config buckets, keyed by plugin name.
110
221
  plugin_states: HashMap<String, PluginScopedConfig>,
111
222
  }
112
223
 
@@ -138,14 +249,9 @@ impl Deref for RendererData {
138
249
  }
139
250
  }
140
251
 
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
252
  impl Renderer {
148
253
  pub fn new(viewer_elem: &HtmlElement) -> Self {
254
+ let draw_lock = DebounceMutex::default();
149
255
  Self(Rc::new(RendererData {
150
256
  plugin_data: RefCell::new(RendererMutData {
151
257
  viewer_elem: viewer_elem.clone(),
@@ -154,10 +260,11 @@ impl Renderer {
154
260
  plugins_idx: None,
155
261
  selection: None,
156
262
  timer: MovingWindowRenderTimer::default(),
157
- pending_plugin: None,
158
263
  plugin_states: HashMap::default(),
159
264
  }),
160
- draw_lock: Default::default(),
265
+ geometry_slot: draw_lock.slot(),
266
+ geometry_cmd: Cell::new(None),
267
+ draw_lock,
161
268
  plugin_changed: Default::default(),
162
269
  style_changed: Default::default(),
163
270
  reset_changed: Default::default(),
@@ -165,524 +272,176 @@ impl Renderer {
165
272
  column_style_changed: Default::default(),
166
273
  plugin_config_changed: Default::default(),
167
274
  render_warning: Cell::new(true),
275
+ presize_pending: Cell::new(0),
168
276
  on_render_limits_changed: Default::default(),
277
+ slot_name: Default::default(),
278
+ theme: Default::default(),
279
+ default_theme: Default::default(),
280
+ has_drawn: Cell::new(false),
281
+ captured_theme: Default::default(),
282
+ cached_context: Default::default(),
283
+ active_context: Default::default(),
284
+ is_active_panel: Cell::new(false),
285
+ is_solo_panel: Cell::new(true),
286
+ data_stale: Cell::new(false),
169
287
  }))
170
288
  }
171
289
 
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(())
182
- }
183
-
184
- pub fn metadata(&self) -> Rc<PluginStaticConfig> {
185
- self.borrow().metadata.clone()
186
- }
187
-
188
- pub fn is_chart(&self) -> bool {
189
- self.metadata().name.as_str() != "Datagrid"
290
+ /// Set the layout slot name (panel id) under which this renderer mounts its
291
+ /// plugin in the viewer's light DOM, routed through the matching
292
+ /// `<regular-layout>` forwarding slot.
293
+ pub fn set_slot_name(&self, name: &str) {
294
+ *self.0.slot_name.borrow_mut() = Some(name.to_owned());
190
295
  }
191
296
 
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
297
+ /// The layout slot name (panel id) for this renderer's plugin, if assigned.
298
+ pub fn slot_name(&self) -> Option<String> {
299
+ self.0.slot_name.borrow().clone()
195
300
  }
196
301
 
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())
302
+ /// Set this panel's theme name (`None` = inherit the element-level theme).
303
+ pub fn set_theme(&self, name: Option<String>) {
304
+ *self.0.theme.borrow_mut() = name;
202
305
  }
203
306
 
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()
307
+ /// This panel's selected theme name, if any (per-panel theming).
308
+ pub fn theme(&self) -> Option<String> {
309
+ self.0.theme.borrow().clone()
216
310
  }
217
311
 
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
- }
312
+ /// [`Self::set_theme`] plus a synchronous [`Self::stamp_theme`] when the
313
+ /// effective theme is resolvable now (a named theme needs no registry; a
314
+ /// reset stamps only from a warm default cache — a cold one would stamp
315
+ /// attribute-removal). The shared "stamp-with-commit" head of every
316
+ /// per-panel theme mutation site.
317
+ pub fn set_theme_stamped(&self, theme: Option<String>) {
318
+ let stamp = theme.is_some() || self.default_theme().is_some();
319
+ self.set_theme(theme);
320
+ if stamp {
321
+ self.stamp_theme(None);
279
322
  }
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
- }
324
- }
325
-
326
- configs
327
323
  }
328
324
 
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
- }
325
+ /// Set the cached registry default theme name (see the field docs on
326
+ /// [`RendererData`]).
327
+ pub fn set_default_theme(&self, name: Option<String>) {
328
+ *self.0.default_theme.borrow_mut() = name;
339
329
  }
340
330
 
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()
331
+ /// The cached registry default theme name.
332
+ pub fn default_theme(&self) -> Option<String> {
333
+ self.0.default_theme.borrow().clone()
354
334
  }
355
335
 
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
- }
336
+ /// This panel's EFFECTIVE theme: its own ([`Self::theme`]), else the
337
+ /// cached registry default ([`Self::default_theme`]) — the value
338
+ /// [`Self::stamp_theme`] stamps.
339
+ pub fn effective_theme(&self) -> Option<String> {
340
+ self.theme().or_else(|| self.default_theme())
341
+ }
421
342
 
422
- changed
423
- },
343
+ /// Whether the active plugin's captured `--psp-*` CSS is STALE — the
344
+ /// effective theme differs from the one stamped at the plugin's last
345
+ /// CSS capture (first paint / last restyle). The state-keyed gate for
346
+ /// every theme-driven `restyle_all` (the `has_drawn` analog for
347
+ /// `restyle` — `PLUGIN_DRAW_INVARIANT_PLAN.md`, captured-theme
348
+ /// revision): a caller that just mutated a theme RECORD may not need a
349
+ /// restyle at all if the plugin's capture already reflects the new
350
+ /// value (e.g. the same restore also performed the first paint,
351
+ /// post-stamp). `false` when no capture exists yet — the owed first
352
+ /// paint captures fresh by construction ("stamp before draw").
353
+ pub fn needs_restyle(&self) -> bool {
354
+ match &*self.0.captured_theme.borrow() {
355
+ Some(captured) => *captured != self.effective_theme(),
356
+ None => false,
424
357
  }
425
358
  }
426
359
 
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.
360
+ /// Dispose a single panel's renderer: delete its active plugin and remove
361
+ /// this panel's element(s) from the viewer's light DOM, scoped by
362
+ /// `slot_name` so sibling panels are untouched (unlike
363
+ /// [`Self::delete`], which clears the entire light DOM). Removes the
364
+ /// plugin (`slot=<id>`) and any panel-scoped aux element it mounted
365
+ /// (e.g. the datagrid toolbar, `slot=statusbar-extra-<id>`).
431
366
  ///
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
- }
367
+ /// The teardown is DEFERRED through this renderer's draw lock: spawned
368
+ /// draws are uncancellable, so a synchronous `plugin.delete()` here could
369
+ /// land mid-`plugin.draw()`, which the serialized-call contract forbids
370
+ /// (see [`crate::js::plugin`]). Deferral is invisible — the caller
371
+ /// (`eject_panel`) has already removed the panel from the `Workspace`, so
372
+ /// the same commit unmounts its frame and the plugin element sits
373
+ /// unslotted (unrendered) until teardown lands; panel ids are never
374
+ /// reused, so it can't collide with a replacement panel's slot. Draws
375
+ /// still queued behind the lock no-op via the synchronous session reset
376
+ /// (`get_view()` → `None`).
377
+ pub fn dispose(&self) -> ApiFuture<()> {
378
+ self.0.has_drawn.set(false);
379
+ self.0.captured_theme.borrow_mut().take();
380
+ self.0.cached_context.borrow_mut().take();
381
+ let this = self.clone();
382
+ ApiFuture::new(async move {
383
+ let renderer = this.clone();
384
+ this.with_lock(async move {
385
+ if let Some(plugin) = renderer.active_plugin() {
386
+ plugin.delete();
387
+ }
460
388
 
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
- }
389
+ let Some(slot) = renderer.slot_name() else {
390
+ return Ok(());
391
+ };
476
392
 
477
- // ─── Plugin-level config (active plugin's bucket) ─────────────────
393
+ let viewer = renderer.plugin_data.borrow().viewer_elem.clone();
394
+ let slots = [slot.clone(), format!("statusbar-extra-{slot}")];
395
+ let children = viewer.children();
396
+ let mut idx = children.length();
397
+ while idx > 0 {
398
+ idx -= 1;
399
+ if let Some(el) = children.item(idx)
400
+ && el.get_attribute("slot").is_some_and(|s| slots.contains(&s))
401
+ {
402
+ let _ = viewer.remove_child(&el);
403
+ }
404
+ }
478
405
 
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())
406
+ Ok(())
487
407
  })
488
- .unwrap_or_default()
408
+ .await
409
+ })
489
410
  }
490
411
 
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();
412
+ pub fn delete(&self) -> ApiResult<()> {
413
+ self.0.has_drawn.set(false);
414
+ self.0.captured_theme.borrow_mut().take();
415
+ self.0.cached_context.borrow_mut().take();
416
+ if let Some(plugin) = self.active_plugin() {
417
+ plugin.delete();
500
418
  }
501
- }
419
+ self.plugin_data.borrow().viewer_elem.set_inner_text("");
420
+ let new_state = Self::new(&self.plugin_data.borrow().viewer_elem);
421
+ std::mem::swap(
422
+ &mut *self.plugin_data.borrow_mut(),
423
+ &mut *new_state.plugin_data.borrow_mut(),
424
+ );
502
425
 
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())
426
+ Ok(())
515
427
  }
516
428
 
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())
429
+ pub fn metadata(&self) -> Rc<PluginStaticConfig> {
430
+ self.borrow().metadata.clone()
584
431
  }
585
432
 
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
- }
433
+ pub fn is_chart(&self) -> bool {
434
+ self.metadata().name.as_str() != "Datagrid"
648
435
  }
649
436
 
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;
437
+ /// Whether the active plugin opts into per-column style controls.
438
+ pub fn can_render_column_styles(&self) -> bool {
439
+ self.metadata().can_render_column_styles
440
+ }
672
441
 
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
- }
442
+ // ─── Per-column config (active plugin's bucket) ───────────────────
683
443
 
684
- changed
685
- }
444
+ // ─── Plugin-level config (active plugin's bucket) ─────────────────
686
445
 
687
446
  /// Whether the active plugin's render warning is currently armed
688
447
  /// (i.e. an oversized view will be capped). Becomes `false` once
@@ -710,18 +469,31 @@ impl Renderer {
710
469
  self.0.borrow_mut().plugin_store.plugin_configs().clone()
711
470
  }
712
471
 
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")?)
472
+ /// The currently-selected plugin, or `None` if none has been selected yet.
473
+ ///
474
+ /// This is a PURE QUERY (command-query separation): unlike
475
+ /// [`Self::ensure_plugin_selected`] it never selects a default plugin and
476
+ /// never triggers the lazy `PluginStore` snapshot before a plugin exists.
477
+ /// Pre-draw / render-path code MUST use this and handle `None` — selecting
478
+ /// a plugin on the first render, before the real plugins register,
479
+ /// permanently pins the per-renderer store to the built-in `Debug`
480
+ /// (init-order race, surfaces on Safari).
481
+ pub fn active_plugin(&self) -> Option<JsPerspectiveViewerPlugin> {
482
+ // Bail on `plugins_idx` BEFORE touching `plugin_store`, so an unselected
483
+ // renderer never snapshots the registry.
484
+ let idx = self.0.borrow().plugins_idx?;
485
+ self.0.borrow_mut().plugin_store.plugins().get(idx).cloned()
486
+ }
487
+
488
+ /// Ensure a plugin is selected — the registry default when nothing has
489
+ /// ever been selected — and return it. The COMMAND half of
490
+ /// [`Self::active_plugin`]: this is the only path that selects a default and
491
+ /// snapshots the store, so it must run only when actually drawing (by which
492
+ /// point real plugins have registered). Errors if no plugins are
493
+ /// registered.
494
+ pub fn ensure_plugin_selected(&self) -> ApiResult<JsPerspectiveViewerPlugin> {
495
+ self.commit_plugin(None)?;
496
+ Ok(self.active_plugin().ok_or("No Plugin")?)
725
497
  }
726
498
 
727
499
  /// Gets a specific `JsPerspectiveViewerPlugin` by name.
@@ -735,25 +507,42 @@ impl Renderer {
735
507
  Ok(result.unwrap())
736
508
  }
737
509
 
510
+ /// Whether the active plugin has completed a draw. A query, not a
511
+ /// command — and an explicit flag rather than the old `is_connected()`
512
+ /// inference, which eager plugin mounting (an element in the DOM before
513
+ /// its first draw) would falsify.
738
514
  pub fn is_plugin_activated(&self) -> ApiResult<bool> {
739
- Ok(self
740
- .get_active_plugin()?
741
- .unchecked_ref::<HtmlElement>()
742
- .is_connected())
515
+ Ok(self.0.has_drawn.get())
516
+ }
517
+
518
+ /// Mount the selected plugin element into the viewer's light DOM without
519
+ /// drawing it (see [`activate::mount_plugin`] — idempotent). Pure query:
520
+ /// a renderer with no selection is a no-op, deferring to the draw path's
521
+ /// `ensure_plugin_selected`. Used to mount eagerly — at panel creation
522
+ /// and at locked-draw start — so a slow first view query never leaves an
523
+ /// empty panel frame. NOTE eager mounting is exactly why
524
+ /// [`Self::is_plugin_activated`] must be an explicit has-drawn flag, not
525
+ /// DOM-connectedness.
526
+ pub fn mount_active_plugin(&self) -> ApiResult<()> {
527
+ if let Some(plugin) = self.active_plugin() {
528
+ let viewer_elem = self.0.borrow().viewer_elem.clone();
529
+ mount_plugin(&viewer_elem, &plugin, self.slot_name().as_deref())?;
530
+ }
531
+
532
+ Ok(())
743
533
  }
744
534
 
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)
535
+ /// Record whether this panel is the active panel (data only — see the
536
+ /// field doc; the CSS class lands at the next locked plugin dispatch).
537
+ pub fn set_active_flag(&self, is_active: bool) {
538
+ self.0.is_active_panel.set(is_active);
539
+ }
540
+
541
+ /// Record whether this panel is the workspace's only panel (data only —
542
+ /// see the field doc; the CSS class lands at the next locked plugin
543
+ /// dispatch).
544
+ pub fn set_solo_flag(&self, is_solo: bool) {
545
+ self.0.is_solo_panel.set(is_solo);
757
546
  }
758
547
 
759
548
  pub fn set_throttle(&self, val: Option<f64>) {
@@ -777,10 +566,22 @@ impl Renderer {
777
566
  self.0.render_warning.set(false);
778
567
  }
779
568
 
780
- pub fn get_next_plugin_metadata(
569
+ /// Resolve a [`PluginUpdate`] against the current selection. Returns the
570
+ /// target plugin's index + static config when the update names a plugin
571
+ /// (or the registry default, for `SetDefault`) that differs from the
572
+ /// current selection; `None` when the update is `Missing`, names an
573
+ /// unknown plugin, or resolves to the already-active one.
574
+ ///
575
+ /// PURE query — nothing is staged on the `Renderer`. Thread the returned
576
+ /// index *into* a locked draw task and commit it there with
577
+ /// [`Self::commit_plugin`]: plugin-swap intent must never exist outside a
578
+ /// running draw transaction, or an unrelated draw that wins the lock first
579
+ /// (e.g. a `table_updated` redraw during a `restore()`) could observe or
580
+ /// commit a half-applied swap.
581
+ pub fn resolve_plugin_update(
781
582
  &self,
782
583
  update: &PluginUpdate,
783
- ) -> Option<Rc<PluginStaticConfig>> {
584
+ ) -> Option<(usize, Rc<PluginStaticConfig>)> {
784
585
  let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
785
586
  let name = match update {
786
587
  PluginUpdate::Missing => return None,
@@ -795,47 +596,43 @@ impl Renderer {
795
596
  );
796
597
 
797
598
  if changed {
798
- self.borrow_mut().pending_plugin = Some(idx);
799
- self.0
599
+ let config = self
600
+ .0
800
601
  .borrow_mut()
801
602
  .plugin_store
802
603
  .plugin_configs()
803
604
  .get(idx)
804
- .cloned()
605
+ .cloned()?;
606
+ Some((idx, config))
805
607
  } else {
806
608
  None
807
609
  }
808
610
  }
809
611
 
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
- }
612
+ /// Commit a plugin selection previously resolved by
613
+ /// [`Self::resolve_plugin_update`]. COMMAND — call only from inside a
614
+ /// locked draw task, so the swap lands atomically with the view rebuild
615
+ /// and draw it belongs to. `None` ensures *some* plugin is selected (the
616
+ /// registry default) without changing an existing selection. Returns
617
+ /// whether the active plugin changed (`None`'s default-selection case
618
+ /// reports `false`, preserving first-selection semantics for the
619
+ /// swap-restore pass in the draw tasks).
620
+ pub fn commit_plugin(&self, idx: Option<usize>) -> ApiResult<bool> {
621
+ let idx = match idx {
622
+ Some(idx) => idx,
623
+ None => {
624
+ if self.0.borrow().plugins_idx.is_none() {
625
+ let name = PLUGIN_REGISTRY.default_plugin_name();
626
+ let idx = self
627
+ .find_plugin_idx(&name)
628
+ .ok_or_else(|| JsValue::from(format!("Unknown plugin '{name}'")))?;
629
+
630
+ self.commit_plugin_idx(idx)?;
631
+ }
831
632
 
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}'")))?;
633
+ return Ok(false);
634
+ },
635
+ };
839
636
 
840
637
  let changed = !matches!(
841
638
  self.0.borrow().plugins_idx,
@@ -849,11 +646,16 @@ impl Renderer {
849
646
  Ok(changed)
850
647
  }
851
648
 
852
- /// Shared tail of `apply_pending_plugin` / `set_plugin`: switch the
649
+ /// Shared tail of [`Self::commit_plugin`]: switch the
853
650
  /// active plugin to `idx`, swap in its cached `PluginStaticConfig`,
854
651
  /// reset the per-plugin render-warning flag, and fire
855
652
  /// `plugin_changed`.
856
653
  fn commit_plugin_idx(&self, idx: usize) -> ApiResult<()> {
654
+ // The newly-selected plugin element has not drawn (a swap keeps the
655
+ // OLD plugin mounted until the new one's draw lands) — and has
656
+ // captured no CSS.
657
+ self.0.has_drawn.set(false);
658
+ self.0.captured_theme.borrow_mut().take();
857
659
  self.borrow_mut().plugins_idx = Some(idx);
858
660
  let config = self
859
661
  .0
@@ -866,7 +668,10 @@ impl Renderer {
866
668
 
867
669
  self.borrow_mut().metadata = config.clone();
868
670
  self.0.render_warning.set(true);
869
- let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
671
+ // `commit_plugin_idx` is called *by* the selection path, so it must use
672
+ // the pure query (never `ensure_plugin_selected`, which would recurse
673
+ // through `commit_plugin`). `plugins_idx` was just set above.
674
+ let plugin: JsPerspectiveViewerPlugin = self.active_plugin().ok_or("No Plugin")?;
870
675
 
871
676
  // Push the newly-activated plugin's stored bucket through
872
677
  // `plugin.restore` so the swap immediately reflects any
@@ -886,192 +691,6 @@ impl Renderer {
886
691
  Ok(())
887
692
  }
888
693
 
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
694
  pub fn render_timer(&self) -> MovingWindowRenderTimer {
1076
695
  self.0.borrow().timer.clone()
1077
696
  }
@@ -1080,10 +699,6 @@ impl Renderer {
1080
699
  let short_name = make_short_name(name);
1081
700
  let mut borrowed = self.0.borrow_mut();
1082
701
  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
702
  let short_names: Vec<String> = configs.iter().map(|c| make_short_name(&c.name)).collect();
1088
703
  if let Some(i) = short_names.iter().position(|n| n == &short_name) {
1089
704
  return Some(i);
@@ -1107,14 +722,7 @@ impl Renderer {
1107
722
  /// suitable for passing as a Yew prop. Called by the root component
1108
723
  /// whenever a renderer-related PubSub event fires.
1109
724
  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();
725
+ let has_plugin = self.active_plugin().is_some();
1118
726
  if has_plugin {
1119
727
  let config = self.metadata();
1120
728
  let plugin_name = Some(config.name.clone());
@@ -1125,8 +733,8 @@ impl Renderer {
1125
733
  .map(|c| c.name.clone())
1126
734
  .collect::<Vec<_>>()
1127
735
  .into();
1128
- let plugin_config = PtrEqRc::new(self.get_plugin_config());
1129
736
 
737
+ let plugin_config = PtrEqRc::new(self.get_plugin_config());
1130
738
  RendererProps {
1131
739
  plugin_name,
1132
740
  config,
@@ -1147,65 +755,3 @@ impl Renderer {
1147
755
  }
1148
756
  }
1149
757
  }
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
- }