@perspective-dev/viewer 4.5.1 → 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 (212) 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.cdn.d.ts +3 -0
  18. package/dist/esm/perspective-viewer.d.ts +9 -0
  19. package/dist/esm/perspective-viewer.inline.d.ts +3 -0
  20. package/dist/esm/perspective-viewer.inline.js +20 -1
  21. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  22. package/dist/esm/perspective-viewer.js +20 -1
  23. package/dist/esm/perspective-viewer.js.map +4 -4
  24. package/dist/esm/plugin.d.ts +71 -5
  25. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  26. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  27. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  28. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  29. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  30. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  31. package/dist/esm/ts-rs/OnUpdateData.d.ts +6 -0
  32. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  33. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  34. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +39 -0
  35. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  36. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  37. package/dist/esm/ts-rs/ViewOnUpdateResp.d.ts +4 -0
  38. package/dist/esm/ts-rs/ViewerConfig.d.ts +6 -2
  39. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  40. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  41. package/dist/wasm/perspective-viewer.d.ts +213 -79
  42. package/dist/wasm/perspective-viewer.js +537 -189
  43. package/dist/wasm/perspective-viewer.wasm +0 -0
  44. package/dist/wasm/perspective-viewer.wasm.d.ts +36 -28
  45. package/dist/wasm/snippets/perspective-viewer-daeef4ceb066e6c7/inline0.js +6 -0
  46. package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline3.js → perspective-viewer-daeef4ceb066e6c7/inline4.js} +5 -0
  47. package/package.json +3 -2
  48. package/src/css/_column_dropdown.css +14 -0
  49. package/src/css/_context_menu.css +14 -0
  50. package/src/css/_dropdown_menu.css +14 -0
  51. package/src/css/_filter_dropdown.css +14 -0
  52. package/src/css/_function_dropdown.css +14 -0
  53. package/src/css/_viewer_bundle.css +41 -0
  54. package/src/css/column-settings-panel.css +1 -1
  55. package/src/css/containers/context-menu.css +101 -0
  56. package/src/css/panel-tab.css +193 -0
  57. package/src/css/plugin-selector.css +7 -10
  58. package/src/css/status-bar.css +75 -81
  59. package/src/css/viewer.css +181 -71
  60. package/src/rust/components/column_dropdown.rs +43 -33
  61. package/src/rust/components/column_selector/active_column.rs +7 -18
  62. package/src/rust/components/column_selector/aggregate_selector.rs +4 -10
  63. package/src/rust/components/column_selector/config_selector.rs +33 -84
  64. package/src/rust/components/column_selector/empty_column.rs +0 -3
  65. package/src/rust/components/column_selector/filter_column.rs +5 -18
  66. package/src/rust/components/column_selector/inactive_column.rs +3 -8
  67. package/src/rust/components/column_selector/invalid_column.rs +0 -4
  68. package/src/rust/components/column_selector/sort_column.rs +3 -6
  69. package/src/rust/components/column_selector.rs +5 -14
  70. package/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_pairs.rs +0 -3
  71. package/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +0 -3
  72. package/src/rust/components/column_settings_sidebar.rs +0 -3
  73. package/src/rust/components/containers/dropdown_menu.rs +1 -3
  74. package/src/rust/components/containers/scroll_panel.rs +1 -3
  75. package/src/rust/components/containers/split_panel.rs +175 -66
  76. package/src/rust/components/containers/tab_list.rs +0 -3
  77. package/src/rust/components/context_menu.rs +130 -0
  78. package/src/rust/components/datetime_column_style.rs +0 -3
  79. package/src/rust/components/empty_row.rs +0 -3
  80. package/src/rust/components/expression_editor.rs +2 -3
  81. package/src/rust/components/filter_dropdown.rs +3 -3
  82. package/src/rust/components/font_loader.rs +2 -2
  83. package/src/rust/components/form/code_editor.rs +0 -3
  84. package/src/rust/components/form/debug.rs +3 -4
  85. package/src/rust/components/function_dropdown.rs +3 -3
  86. package/src/rust/components/global_filter_bar.rs +76 -0
  87. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  88. package/src/rust/components/main_panel/msg.rs +62 -0
  89. package/src/rust/components/main_panel/presize.rs +220 -0
  90. package/src/rust/components/main_panel/reconcile.rs +224 -0
  91. package/src/rust/components/main_panel/render.rs +385 -0
  92. package/src/rust/components/main_panel/update.rs +184 -0
  93. package/src/rust/components/main_panel.rs +311 -71
  94. package/src/rust/components/mod.rs +4 -0
  95. package/src/rust/components/number_series_style.rs +0 -3
  96. package/src/rust/components/panel_menu.rs +486 -0
  97. package/src/rust/components/panel_tab.rs +505 -0
  98. package/src/rust/components/plugin_selector.rs +0 -3
  99. package/src/rust/components/plugin_tab.rs +1 -6
  100. package/src/rust/components/portal.rs +5 -2
  101. package/src/rust/components/render_warning.rs +0 -3
  102. package/src/rust/components/settings_panel.rs +13 -5
  103. package/src/rust/components/status_bar.rs +113 -180
  104. package/src/rust/components/status_indicator.rs +6 -9
  105. package/src/rust/components/string_column_style.rs +0 -3
  106. package/src/rust/components/style/mod.rs +65 -27
  107. package/src/rust/components/style/style_provider.rs +51 -16
  108. package/src/rust/components/style_controls/number_string_format.rs +1 -8
  109. package/src/rust/components/type_icon.rs +1 -4
  110. package/src/rust/components/viewer/filters.rs +133 -0
  111. package/src/rust/components/viewer/msg.rs +180 -0
  112. package/src/rust/components/viewer/panels.rs +326 -0
  113. package/src/rust/components/viewer/render.rs +243 -0
  114. package/src/rust/components/viewer/settings.rs +402 -0
  115. package/src/rust/components/viewer/snapshots.rs +134 -0
  116. package/src/rust/components/viewer/wiring.rs +505 -0
  117. package/src/rust/components/viewer.rs +243 -758
  118. package/src/rust/config/export_method.rs +1 -1
  119. package/src/rust/config/mod.rs +4 -0
  120. package/src/rust/config/options.rs +71 -0
  121. package/src/rust/config/viewer_config.rs +24 -2
  122. package/src/rust/config/workspace_config.rs +92 -0
  123. package/src/rust/custom_elements/copy_dropdown.rs +9 -3
  124. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  125. package/src/rust/custom_elements/export_dropdown.rs +9 -3
  126. package/src/rust/custom_elements/viewer.rs +1015 -286
  127. package/src/rust/custom_events.rs +212 -61
  128. package/src/rust/js/mod.rs +2 -0
  129. package/src/rust/js/plugin.rs +297 -10
  130. package/src/rust/js/regular_layout.rs +258 -0
  131. package/src/rust/lib.rs +20 -2
  132. package/src/rust/presentation.rs +66 -33
  133. package/src/rust/queries/get_viewer_config.rs +29 -11
  134. package/src/rust/queries/plugin_column_styles.rs +2 -2
  135. package/src/rust/renderer/activate.rs +41 -14
  136. package/src/rust/renderer/dispatch.rs +436 -0
  137. package/src/rust/renderer/plugin_config.rs +495 -0
  138. package/src/rust/renderer/registry.rs +18 -6
  139. package/src/rust/renderer/render_timer.rs +29 -10
  140. package/src/rust/renderer.rs +350 -827
  141. package/src/rust/session/column_defaults_update.rs +26 -8
  142. package/src/rust/session/metadata.rs +11 -1
  143. package/src/rust/session/view_subscription.rs +30 -15
  144. package/src/rust/session.rs +753 -251
  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/eject.rs +64 -23
  152. package/src/rust/tasks/mod.rs +28 -4
  153. package/src/rust/tasks/pipeline.rs +411 -0
  154. package/src/rust/tasks/presize_panels.rs +324 -0
  155. package/src/rust/tasks/reset_all.rs +22 -12
  156. package/src/rust/tasks/resize_observer.rs +124 -87
  157. package/src/rust/tasks/restore_and_render.rs +80 -86
  158. package/src/rust/tasks/restore_panel.rs +145 -0
  159. package/src/rust/tasks/send_column_config.rs +5 -2
  160. package/src/rust/tasks/send_plugin_config.rs +5 -2
  161. package/src/rust/tasks/set_edit_mode.rs +59 -0
  162. package/src/rust/tasks/sync_update_panels.rs +152 -0
  163. package/src/rust/tasks/table_lifecycle.rs +158 -0
  164. package/src/rust/tasks/update_theme.rs +56 -12
  165. package/src/rust/utils/completion.rs +56 -0
  166. package/src/rust/utils/custom_element.rs +5 -0
  167. package/src/rust/utils/debounce.rs +130 -7
  168. package/src/rust/utils/mod.rs +6 -0
  169. package/src/rust/utils/pending_effects.rs +62 -0
  170. package/src/rust/{components/style/local_style.rs → utils/spawn.rs} +17 -19
  171. package/src/rust/utils/weak_scope.rs +0 -10
  172. package/src/rust/workspace.rs +846 -0
  173. package/src/svg/active-indicator.svg +3 -0
  174. package/src/themes/botanical.css +7 -2
  175. package/src/themes/defaults.css +54 -2
  176. package/src/themes/dracula.css +8 -3
  177. package/src/themes/gruvbox-dark.css +7 -2
  178. package/src/themes/gruvbox.css +5 -2
  179. package/src/themes/icons.css +4 -2
  180. package/src/themes/monokai.css +7 -2
  181. package/src/themes/phosphor.css +7 -2
  182. package/src/themes/pro-dark.css +7 -2
  183. package/src/themes/pro.css +2 -1
  184. package/src/themes/solarized-dark.css +5 -2
  185. package/src/themes/solarized.css +5 -2
  186. package/src/themes/vaporwave.css +7 -2
  187. package/src/ts/bootstrap.ts +8 -7
  188. package/src/ts/extensions.ts +30 -2
  189. package/src/ts/perspective-viewer.ts +10 -0
  190. package/src/ts/plugin.ts +72 -5
  191. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  192. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  193. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  194. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  195. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  196. package/src/ts/ts-rs/Layout.ts +9 -0
  197. package/src/ts/ts-rs/Orientation.ts +6 -0
  198. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  199. package/src/ts/ts-rs/PanelViewerConfig.ts +15 -0
  200. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  201. package/src/ts/ts-rs/TabLayout.ts +11 -0
  202. package/src/ts/ts-rs/ViewerConfig.ts +6 -2
  203. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  204. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  205. package/tsconfig.json +1 -1
  206. package/src/rust/components/style/style_cache.rs +0 -136
  207. package/src/rust/tasks/intersection_observer.rs +0 -98
  208. package/src/rust/tasks/update_and_render.rs +0 -110
  209. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline0.js → perspective-viewer-daeef4ceb066e6c7/inline1.js} +0 -0
  210. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline1.js → perspective-viewer-daeef4ceb066e6c7/inline2.js} +0 -0
  211. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline2.js → perspective-viewer-daeef4ceb066e6c7/inline3.js} +0 -0
  212. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/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,514 +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
- let Some(view_type) = session.metadata().get_column_view_type(column_name) else {
548
- return Ok(ColumnConfigSchema { fields: vec![] });
549
- };
550
-
551
- let current_js = JsValue::from_serde_ext(&current_value).unwrap_or(JsValue::NULL);
552
- let view_config_js = JsValue::from_serde_ext(view_config).unwrap_or(JsValue::NULL);
553
-
554
- // Pull the column's cached stats from the session. The StyleTab
555
- // pre-warms this via `fetch_column_abs_max` whenever the user
556
- // opens column settings; the cache is invalidated on every
557
- // `view_config_changed`, so freshness is bounded.
558
- let stats = session.get_column_stats(column_name).unwrap_or_default();
559
- let stats_json = serde_json::json!({
560
- "abs_max": stats.abs_max,
561
- });
562
- let stats_js = JsValue::from_serde_ext(&stats_json).unwrap_or(JsValue::NULL);
563
-
564
- let raw = plugin._column_config_schema(
565
- &view_type.to_string(),
566
- group,
567
- column_name,
568
- &current_js,
569
- &view_config_js,
570
- &stats_js,
571
- )?;
572
-
573
- serde_wasm_bindgen::from_value(raw).map_err(|e| e.into())
396
+ pub fn metadata(&self) -> Rc<PluginStaticConfig> {
397
+ self.borrow().metadata.clone()
574
398
  }
575
399
 
576
- /// Wholesale update the active plugin's plugin-level config map.
577
- /// Entries whose value equals the schema-declared default are
578
- /// treated as "reset this key" — the corresponding bucket entry
579
- /// is cleared rather than the default being stored literally.
580
- /// Keys absent from the incoming map are left alone (merge
581
- /// semantics for the non-default subset).
582
- pub fn update_plugin_config(
583
- &self,
584
- view_config: &ViewConfig,
585
- update: PluginConfigUpdate,
586
- ) -> bool {
587
- let Some(n) = self.active_plugin_name() else {
588
- return false;
589
- };
590
-
591
- let schema = self.query_plugin_config_schema(view_config).ok();
592
- let mut st = self.borrow_mut();
593
- let bucket = st.plugin_states.entry(n).or_default();
594
- match update {
595
- OptionalUpdate::SetDefault => {
596
- let changed = !bucket.plugin.is_empty();
597
- bucket.plugin.clear();
598
- changed
599
- },
600
- OptionalUpdate::Missing => false,
601
- OptionalUpdate::Update(mut map) => {
602
- let mut changed = false;
603
- if let Some(s) = &schema {
604
- let active = s.active_keys();
605
- map.retain(|k, _| active.contains(k));
606
- // Default-valued entries in a restore payload
607
- // semantically reset the key — strip from the
608
- // map AND clear any existing override in the
609
- // bucket so the wholesale-restore path matches
610
- // the live-edit path (where the widget emits an
611
- // empty value to clear).
612
- map.retain(|key, value| {
613
- let is_default = s
614
- .fields
615
- .iter()
616
- .any(|spec| matches_declared_default(spec, key, value));
617
- if is_default {
618
- if bucket.plugin.remove(key).is_some() {
619
- changed = true;
620
- }
621
- false
622
- } else {
623
- true
624
- }
625
- });
626
- }
627
-
628
- for (k, v) in map {
629
- let prev = bucket.plugin.insert(k, v.clone());
630
- if prev.as_ref() != Some(&v) {
631
- changed = true;
632
- }
633
- }
634
-
635
- changed
636
- },
637
- }
400
+ pub fn is_chart(&self) -> bool {
401
+ self.metadata().name.as_str() != "Datagrid"
638
402
  }
639
403
 
640
- /// Apply a single schema-field update from the plugin-settings UI
641
- /// to the active plugin's bucket. Clear-then-insert semantics
642
- /// mirror [`Self::update_columns_config_field`]. Entries in
643
- /// `update.value` whose value equals the schema default are
644
- /// stripped before applying so default picks reset the key
645
- /// rather than store the default literally.
646
- pub fn update_plugin_config_field(
647
- &self,
648
- view_config: &ViewConfig,
649
- mut update: ColumnConfigFieldUpdate,
650
- ) -> bool {
651
- let Some(n) = self.active_plugin_name() else {
652
- return false;
653
- };
654
-
655
- if let Ok(schema) = self.query_plugin_config_schema(view_config) {
656
- strip_default_values(&schema, &mut update.value);
657
- }
658
-
659
- let mut st = self.borrow_mut();
660
- let bucket = st.plugin_states.entry(n).or_default();
661
- 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
+ }
662
408
 
663
- for k in &update.keys {
664
- if let Some(v) = update.value.get(k) {
665
- let prev = bucket.plugin.insert(k.to_string(), v.clone());
666
- if prev.as_ref() != Some(v) {
667
- changed = true;
668
- }
669
- } else if bucket.plugin.remove(k).is_some() {
670
- changed = true;
671
- }
672
- }
409
+ // ─── Per-column config (active plugin's bucket) ───────────────────
673
410
 
674
- changed
675
- }
411
+ // ─── Plugin-level config (active plugin's bucket) ─────────────────
676
412
 
677
413
  /// Whether the active plugin's render warning is currently armed
678
414
  /// (i.e. an oversized view will be capped). Becomes `false` once
@@ -700,18 +436,31 @@ impl Renderer {
700
436
  self.0.borrow_mut().plugin_store.plugin_configs().clone()
701
437
  }
702
438
 
703
- /// Gets the currently active plugin. Calling this method before a plugin
704
- /// has been selected will cause the default (first) plugin to be
705
- /// selected, and doing so when no plugins have been registered is an
706
- /// error.
707
- pub fn get_active_plugin(&self) -> ApiResult<JsPerspectiveViewerPlugin> {
708
- if self.0.borrow().plugins_idx.is_none() {
709
- let _ = self.apply_pending_plugin()?;
710
- }
711
-
712
- let idx = self.0.borrow().plugins_idx.unwrap_or(0);
713
- let result = self.0.borrow_mut().plugin_store.plugins().get(idx).cloned();
714
- 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")?)
715
464
  }
716
465
 
717
466
  /// Gets a specific `JsPerspectiveViewerPlugin` by name.
@@ -725,25 +474,42 @@ impl Renderer {
725
474
  Ok(result.unwrap())
726
475
  }
727
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.
728
481
  pub fn is_plugin_activated(&self) -> ApiResult<bool> {
729
- Ok(self
730
- .get_active_plugin()?
731
- .unchecked_ref::<HtmlElement>()
732
- .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);
733
506
  }
734
507
 
735
- pub async fn restyle_all(&self, view: &perspective_client::View) -> ApiResult<JsValue> {
736
- let plugin = self.get_active_plugin()?;
737
- let meta = self.metadata();
738
- plugin.restyle();
739
- let mut limits =
740
- get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
741
- limits.is_update = false;
742
- plugin
743
- .draw(view.clone().into(), limits.max_cols, limits.max_rows, false)
744
- .await?;
745
-
746
- 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);
747
513
  }
748
514
 
749
515
  pub fn set_throttle(&self, val: Option<f64>) {
@@ -767,10 +533,22 @@ impl Renderer {
767
533
  self.0.render_warning.set(false);
768
534
  }
769
535
 
770
- 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(
771
549
  &self,
772
550
  update: &PluginUpdate,
773
- ) -> Option<Rc<PluginStaticConfig>> {
551
+ ) -> Option<(usize, Rc<PluginStaticConfig>)> {
774
552
  let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
775
553
  let name = match update {
776
554
  PluginUpdate::Missing => return None,
@@ -785,47 +563,43 @@ impl Renderer {
785
563
  );
786
564
 
787
565
  if changed {
788
- self.borrow_mut().pending_plugin = Some(idx);
789
- self.0
566
+ let config = self
567
+ .0
790
568
  .borrow_mut()
791
569
  .plugin_store
792
570
  .plugin_configs()
793
571
  .get(idx)
794
- .cloned()
572
+ .cloned()?;
573
+ Some((idx, config))
795
574
  } else {
796
575
  None
797
576
  }
798
577
  }
799
578
 
800
- pub fn apply_pending_plugin(&self) -> ApiResult<bool> {
801
- let xxx = self.borrow_mut().pending_plugin.take();
802
- if let Some(idx) = xxx {
803
- let changed = !matches!(
804
- self.0.borrow().plugins_idx,
805
- Some(selected_idx) if selected_idx == idx
806
- );
807
-
808
- if changed {
809
- self.commit_plugin_idx(idx)?;
810
- }
811
-
812
- Ok(changed)
813
- } else {
814
- if self.0.borrow().plugins_idx.is_none() {
815
- self.set_plugin(Some(&PLUGIN_REGISTRY.default_plugin_name()))?;
816
- }
817
-
818
- Ok(false)
819
- }
820
- }
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
+ }
821
599
 
822
- fn set_plugin(&self, name: Option<&str>) -> ApiResult<bool> {
823
- self.borrow_mut().pending_plugin = None;
824
- let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
825
- let name = name.unwrap_or(default_plugin_name.as_str());
826
- let idx = self
827
- .find_plugin_idx(name)
828
- .ok_or_else(|| JsValue::from(format!("Unknown plugin '{name}'")))?;
600
+ return Ok(false);
601
+ },
602
+ };
829
603
 
830
604
  let changed = !matches!(
831
605
  self.0.borrow().plugins_idx,
@@ -839,11 +613,16 @@ impl Renderer {
839
613
  Ok(changed)
840
614
  }
841
615
 
842
- /// Shared tail of `apply_pending_plugin` / `set_plugin`: switch the
616
+ /// Shared tail of [`Self::commit_plugin`]: switch the
843
617
  /// active plugin to `idx`, swap in its cached `PluginStaticConfig`,
844
618
  /// reset the per-plugin render-warning flag, and fire
845
619
  /// `plugin_changed`.
846
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();
847
626
  self.borrow_mut().plugins_idx = Some(idx);
848
627
  let config = self
849
628
  .0
@@ -856,7 +635,10 @@ impl Renderer {
856
635
 
857
636
  self.borrow_mut().metadata = config.clone();
858
637
  self.0.render_warning.set(true);
859
- 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")?;
860
642
 
861
643
  // Push the newly-activated plugin's stored bucket through
862
644
  // `plugin.restore` so the swap immediately reflects any
@@ -876,192 +658,6 @@ impl Renderer {
876
658
  Ok(())
877
659
  }
878
660
 
879
- pub async fn with_lock<T>(self, task: impl Future<Output = ApiResult<T>>) -> ApiResult<T> {
880
- let draw_mutex = self.draw_lock();
881
- draw_mutex.lock(task).await
882
- }
883
-
884
- pub async fn resize(&self) -> ApiResult<()> {
885
- let draw_mutex = self.draw_lock();
886
- let timer = self.render_timer();
887
- draw_mutex
888
- .debounce(async {
889
- set_timeout(timer.get_throttle()).await?;
890
- let jsplugin = self.get_active_plugin()?;
891
- jsplugin.resize().await?;
892
- Ok(())
893
- })
894
- .await
895
- }
896
-
897
- pub async fn resize_with_dimensions(&self, width: f64, height: f64) -> ApiResult<()> {
898
- let draw_mutex = self.draw_lock();
899
- let timer = self.render_timer();
900
- draw_mutex
901
- .debounce(async {
902
- set_timeout(timer.get_throttle()).await?;
903
- let plugin = self.get_active_plugin()?;
904
- let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
905
- let rect = main_panel.get_bounding_client_rect();
906
- if (height - rect.height()).abs() > 0.5 || (width - rect.width()).abs() > 0.5 {
907
- let new_width = format!("{}px", width);
908
- let new_height = format!("{}px", height);
909
- main_panel.style().set_property("width", &new_width)?;
910
- main_panel.style().set_property("height", &new_height)?;
911
- let result = plugin.resize().await;
912
- main_panel.style().set_property("width", "")?;
913
- main_panel.style().set_property("height", "")?;
914
- result?;
915
- }
916
-
917
- Ok(())
918
- })
919
- .await
920
- }
921
-
922
- /// This will take a future which _should_ create a new view and then will
923
- /// draw it. As the `session` closure is asynchronous, it can be cancelled
924
- /// by returning `None`.
925
- pub async fn draw(
926
- &self,
927
- session: impl Future<Output = ApiResult<Option<View>>>,
928
- ) -> ApiResult<()> {
929
- self.draw_plugin(session, false).await
930
- }
931
-
932
- /// This will update an already existing view
933
- pub async fn update(&self, session: Option<View>) -> ApiResult<()> {
934
- self.draw_plugin(async { Ok(session) }, true).await
935
- }
936
-
937
- async fn draw_plugin(
938
- &self,
939
- session: impl Future<Output = ApiResult<Option<View>>>,
940
- is_update: bool,
941
- ) -> ApiResult<()> {
942
- let timer = self.render_timer();
943
- let task = async move {
944
- if is_update {
945
- set_timeout(timer.get_throttle()).await?;
946
- }
947
-
948
- if let Some(view) = session.await? {
949
- timer.capture_time(self.draw_view(&view, is_update)).await
950
- } else {
951
- tracing::debug!("Render skipped, no `View` attached");
952
- Ok(())
953
- }
954
- };
955
-
956
- let draw_mutex = self.draw_lock();
957
- if is_update {
958
- draw_mutex.debounce(task).await
959
- } else {
960
- draw_mutex.lock(task).await
961
- }
962
- }
963
-
964
- async fn draw_view(&self, view: &perspective_client::View, is_update: bool) -> ApiResult<()> {
965
- let plugin = self.get_active_plugin()?;
966
- let meta = self.metadata();
967
- let mut limits =
968
- get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
969
- limits.is_update = is_update;
970
- if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() {
971
- cb.emit(limits);
972
- }
973
-
974
- let viewer_elem = &self.0.borrow().viewer_elem.clone();
975
- let result = if is_update {
976
- let task = plugin.update(view.clone().into(), limits.max_cols, limits.max_rows, false);
977
- activate_plugin(viewer_elem, &plugin, task).await
978
- } else {
979
- let task = plugin.draw(view.clone().into(), limits.max_cols, limits.max_rows, false);
980
- activate_plugin(viewer_elem, &plugin, task).await
981
- };
982
-
983
- if let Err(error) = result.ignore_view_delete() {
984
- tracing::warn!("{}", error);
985
- }
986
-
987
- remove_inactive_plugin(
988
- viewer_elem,
989
- &plugin,
990
- self.plugin_data.borrow_mut().plugin_store.plugins(),
991
- )
992
- }
993
-
994
- /// Decide whether to draw plugin or self first based on whether the panel
995
- /// is opening or closing, then draw with a timeout. If the timeout
996
- /// triggers, draw self and resolve `on_toggle` but still await the
997
- /// completion of the draw task.
998
- pub async fn presize(
999
- &self,
1000
- open: bool,
1001
- panel_task: impl Future<Output = ApiResult<()>>,
1002
- ) -> ApiResult<JsValue> {
1003
- let render_task = self.resize_with_timeout(open);
1004
- let result = if open {
1005
- panel_task.await?;
1006
- render_task.await
1007
- } else {
1008
- let result = render_task.await;
1009
- panel_task.await?;
1010
- result
1011
- };
1012
-
1013
- match result {
1014
- Ok(x) => x,
1015
- Err(cont) => {
1016
- tracing::warn!("Presize took longer than {}ms", PRESIZE_TIMEOUT);
1017
- cont.await.unwrap()
1018
- },
1019
- }
1020
- }
1021
-
1022
- /// Lock on `resize()` task, in parallel with a timeout. In the return
1023
- /// type, `Result::Err` contains the continuation task, which must be
1024
- /// awaited lest the plugin draw itself never trigger.
1025
- async fn resize_with_timeout(&self, open: bool) -> Result<TaskResult, TimeoutTask<'_>> {
1026
- let task = async move {
1027
- if open {
1028
- self.get_active_plugin()?.resize().await
1029
- } else {
1030
- self.resize_with_explicit_dimensions().await
1031
- }
1032
- };
1033
-
1034
- let draw_lock = self.draw_lock();
1035
- let tasks: [TimeoutTask<'_>; 2] = [
1036
- Box::pin(async move { Some(draw_lock.lock(task).await) }),
1037
- Box::pin(async {
1038
- set_timeout(PRESIZE_TIMEOUT).await.unwrap();
1039
- None
1040
- }),
1041
- ];
1042
-
1043
- let (x, _, y) = select_all(tasks.into_iter()).await;
1044
- x.ok_or_else(|| y.into_iter().next().unwrap())
1045
- }
1046
-
1047
- /// Resize the `<div>` offscreen, then resize the plugin
1048
- async fn resize_with_explicit_dimensions(&self) -> TaskResult {
1049
- let plugin = self.get_active_plugin()?;
1050
- let main_panel: &web_sys::HtmlElement = plugin.unchecked_ref();
1051
- let new_width = format!("{}px", &self.0.borrow().viewer_elem.client_width());
1052
- let new_height = format!("{}px", &self.0.borrow().viewer_elem.client_height());
1053
- main_panel.style().set_property("width", &new_width)?;
1054
- main_panel.style().set_property("height", &new_height)?;
1055
- let result = plugin.resize().await;
1056
- main_panel.style().set_property("width", "")?;
1057
- main_panel.style().set_property("height", "")?;
1058
- result
1059
- }
1060
-
1061
- fn draw_lock(&self) -> DebounceMutex {
1062
- self.draw_lock.clone()
1063
- }
1064
-
1065
661
  pub fn render_timer(&self) -> MovingWindowRenderTimer {
1066
662
  self.0.borrow().timer.clone()
1067
663
  }
@@ -1070,10 +666,6 @@ impl Renderer {
1070
666
  let short_name = make_short_name(name);
1071
667
  let mut borrowed = self.0.borrow_mut();
1072
668
  let configs = borrowed.plugin_store.plugin_configs();
1073
- // Prefer an exact (normalised) match so e.g. `"Y Line"` doesn't
1074
- // substring-resolve to `"X/Y Line"` just because it was registered
1075
- // first. Falls back to `contains` so short/abbreviated names
1076
- // (`restore({ plugin: "scat" })` → `"scatter"`) still work.
1077
669
  let short_names: Vec<String> = configs.iter().map(|c| make_short_name(&c.name)).collect();
1078
670
  if let Some(i) = short_names.iter().position(|n| n == &short_name) {
1079
671
  return Some(i);
@@ -1097,14 +689,7 @@ impl Renderer {
1097
689
  /// suitable for passing as a Yew prop. Called by the root component
1098
690
  /// whenever a renderer-related PubSub event fires.
1099
691
  pub fn to_props(&self, render_limits: Option<RenderLimits>) -> RendererProps {
1100
- // Guard: don't touch the PluginStore if no plugin has been explicitly
1101
- // selected yet. Calling `get_active_plugin()` or `get_all_plugins()`
1102
- // triggers `PluginStore::init_lazy()`, which snapshots the
1103
- // PLUGIN_REGISTRY. If this happens during component `create()` —
1104
- // before JavaScript has called `registerPlugin()` — the cache will
1105
- // only contain the default Debug plugin and custom plugins registered
1106
- // later will never be found.
1107
- let has_plugin = self.0.borrow().plugins_idx.is_some();
692
+ let has_plugin = self.active_plugin().is_some();
1108
693
  if has_plugin {
1109
694
  let config = self.metadata();
1110
695
  let plugin_name = Some(config.name.clone());
@@ -1115,8 +700,8 @@ impl Renderer {
1115
700
  .map(|c| c.name.clone())
1116
701
  .collect::<Vec<_>>()
1117
702
  .into();
1118
- let plugin_config = PtrEqRc::new(self.get_plugin_config());
1119
703
 
704
+ let plugin_config = PtrEqRc::new(self.get_plugin_config());
1120
705
  RendererProps {
1121
706
  plugin_name,
1122
707
  config,
@@ -1137,65 +722,3 @@ impl Renderer {
1137
722
  }
1138
723
  }
1139
724
  }
1140
-
1141
- /// Drop entries from `map` whose value matches the schema-declared
1142
- /// default for that key. Used by both the plugin-config and
1143
- /// columns-config write paths to converge buckets to the
1144
- /// "empty ⇒ reads-default" invariant. For `ControlSpec::Number`
1145
- /// entries marked `include: Some(true)`,
1146
- /// [`matches_declared_default`] short-circuits so the value survives
1147
- /// (used when the declared default is data-dependent and unreliable —
1148
- /// e.g. Datagrid's `fg_gradient`, whose default is the column's
1149
- /// `abs_max`).
1150
- fn strip_default_values(
1151
- schema: &ColumnConfigSchema,
1152
- map: &mut serde_json::Map<String, serde_json::Value>,
1153
- ) {
1154
- map.retain(|key, value| {
1155
- !schema
1156
- .fields
1157
- .iter()
1158
- .any(|spec| matches_declared_default(spec, key, value))
1159
- });
1160
- }
1161
-
1162
- /// Does `value` for `key` match the `default` declared by `spec`?
1163
- /// Composite variants (`NumberSeriesStyle`, `DatetimeFormat`, etc.) own
1164
- /// nested defaults that don't have a single comparable scalar — the
1165
- /// widget is responsible for emitting empty values when the user
1166
- /// resets composite controls, so this helper returns `false` for them.
1167
- fn matches_declared_default(spec: &ControlSpec, key: &str, value: &Value) -> bool {
1168
- match spec {
1169
- ControlSpec::Enum {
1170
- key: k, default, ..
1171
- } if k == key => value.as_str() == Some(default.as_str()),
1172
- ControlSpec::Bool {
1173
- key: k, default, ..
1174
- } if k == key => value.as_bool() == Some(*default),
1175
- ControlSpec::Number {
1176
- key: k,
1177
- include: Some(true),
1178
- ..
1179
- } if k == key => false,
1180
- ControlSpec::Number {
1181
- key: k, default, ..
1182
- } if k == key => value.as_f64() == Some(*default),
1183
- ControlSpec::String {
1184
- key: k, default, ..
1185
- } if k == key => value.as_str() == Some(default.as_str()),
1186
- ControlSpec::Color {
1187
- key: k, default, ..
1188
- } if k == key => value.as_str() == Some(default.as_str()),
1189
- ControlSpec::ColorRange {
1190
- key_pos,
1191
- default_pos,
1192
- ..
1193
- } if key_pos == key => value.as_str() == Some(default_pos.as_str()),
1194
- ControlSpec::ColorRange {
1195
- key_neg,
1196
- default_neg,
1197
- ..
1198
- } if key_neg == key => value.as_str() == Some(default_neg.as_str()),
1199
- _ => false,
1200
- }
1201
- }