@perspective-dev/viewer 4.4.1 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/dist/cdn/perspective-viewer.js +1 -2
  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/bootstrap.d.ts +2 -1
  24. package/dist/esm/column-format.d.ts +51 -0
  25. package/dist/esm/extensions.d.ts +2 -0
  26. package/dist/esm/perspective-viewer.d.ts +3 -1
  27. package/dist/esm/perspective-viewer.inline.js +1 -2
  28. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  29. package/dist/esm/perspective-viewer.js +1 -2
  30. package/dist/esm/perspective-viewer.js.map +4 -4
  31. package/dist/esm/perspective-viewer.worker.d.ts +2 -0
  32. package/dist/esm/plugin.d.ts +16 -72
  33. package/dist/esm/ts-rs/ColumnSelectMode.d.ts +1 -0
  34. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +77 -0
  35. package/dist/esm/ts-rs/ViewerConfig.d.ts +6 -3
  36. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -4
  37. package/dist/wasm/perspective-viewer.d.ts +77 -18
  38. package/dist/wasm/perspective-viewer.js +302 -148
  39. package/dist/wasm/perspective-viewer.wasm +0 -0
  40. package/dist/wasm/perspective-viewer.wasm.d.ts +20 -15
  41. package/package.json +24 -2
  42. package/src/css/column-selector.css +3 -2
  43. package/src/css/column-settings-panel.css +44 -9
  44. package/src/css/column-style.css +35 -2
  45. package/src/css/containers/scroll-panel.css +2 -1
  46. package/src/css/containers/tabs.css +8 -52
  47. package/src/css/dom/checkbox.css +2 -6
  48. package/src/css/form/code-editor.css +1 -0
  49. package/src/css/form/debug.css +3 -10
  50. package/src/css/plugin-selector.css +33 -0
  51. package/src/css/plugin-settings-panel.css +99 -0
  52. package/src/css/viewer.css +143 -3
  53. package/src/rust/components/column_dropdown.rs +3 -1
  54. package/src/rust/components/column_selector/active_column.rs +16 -19
  55. package/src/rust/components/column_selector/config_selector.rs +20 -20
  56. package/src/rust/components/column_selector/filter_column.rs +14 -14
  57. package/src/rust/components/column_selector/inactive_column.rs +10 -15
  58. package/src/rust/components/column_selector/pivot_column.rs +7 -7
  59. package/src/rust/components/column_selector/sort_column.rs +7 -7
  60. package/src/rust/components/column_selector.rs +55 -37
  61. package/src/rust/components/column_settings_sidebar/style_tab/agg_depth_selector.rs +15 -7
  62. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +395 -0
  63. package/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +15 -6
  64. package/src/rust/components/column_settings_sidebar/style_tab.rs +267 -136
  65. package/src/rust/components/column_settings_sidebar.rs +44 -49
  66. package/src/rust/components/containers/dragdrop_list.rs +32 -5
  67. package/src/rust/components/containers/mod.rs +0 -1
  68. package/src/rust/components/containers/scroll_panel.rs +21 -7
  69. package/src/rust/components/containers/sidebar.rs +8 -6
  70. package/src/rust/components/containers/split_panel.rs +3 -3
  71. package/src/rust/components/containers/tab_list.rs +3 -9
  72. package/src/rust/components/copy_dropdown.rs +2 -3
  73. package/src/rust/components/datetime_column_style.rs +19 -81
  74. package/src/rust/components/editable_header.rs +17 -3
  75. package/src/rust/components/export_dropdown.rs +2 -3
  76. package/src/rust/components/expression_editor.rs +29 -17
  77. package/src/rust/components/filter_dropdown.rs +2 -1
  78. package/src/rust/components/form/color_range_selector.rs +14 -7
  79. package/src/rust/components/form/debug.rs +47 -37
  80. package/src/rust/components/main_panel.rs +24 -65
  81. package/src/rust/components/mod.rs +2 -1
  82. package/src/rust/components/number_series_style.rs +161 -0
  83. package/src/rust/components/plugin_tab.rs +221 -0
  84. package/src/rust/components/settings_panel.rs +181 -59
  85. package/src/rust/components/status_bar.rs +141 -174
  86. package/src/rust/components/status_indicator.rs +15 -22
  87. package/src/rust/components/string_column_style.rs +20 -82
  88. package/src/rust/components/style_controls/number_string_format.rs +14 -30
  89. package/src/rust/components/viewer.rs +169 -132
  90. package/src/rust/config/column_config_schema.rs +195 -0
  91. package/src/rust/config/columns_config.rs +4 -97
  92. package/src/rust/config/datetime_column_style.rs +0 -5
  93. package/src/rust/config/mod.rs +8 -2
  94. package/src/rust/config/number_series_style.rs +79 -0
  95. package/src/rust/config/plugin_static_config.rs +144 -0
  96. package/src/rust/config/string_column_style.rs +0 -5
  97. package/src/rust/config/viewer_config.rs +5 -6
  98. package/src/rust/custom_elements/copy_dropdown.rs +30 -18
  99. package/src/rust/custom_elements/debug_plugin.rs +1 -3
  100. package/src/rust/custom_elements/export_dropdown.rs +26 -18
  101. package/src/rust/custom_elements/viewer.rs +62 -73
  102. package/src/rust/custom_events.rs +181 -224
  103. package/src/rust/js/plugin.rs +45 -117
  104. package/src/rust/lib.rs +34 -5
  105. package/src/rust/presentation/drag_helpers.rs +206 -0
  106. package/src/rust/presentation/props.rs +8 -0
  107. package/src/rust/presentation.rs +256 -41
  108. package/src/rust/{tasks → queries}/column_locator.rs +17 -73
  109. package/src/rust/queries/column_values.rs +59 -0
  110. package/src/rust/{tasks → queries}/columns_iter_set.rs +11 -18
  111. package/src/rust/queries/exports.rs +96 -0
  112. package/src/rust/queries/fetch_column_stats.rs +94 -0
  113. package/src/rust/queries/get_viewer_config.rs +54 -0
  114. package/src/rust/queries/mod.rs +44 -0
  115. package/src/rust/queries/plugin_column_styles.rs +101 -0
  116. package/src/rust/{engines.rs → queries/validate_expression.rs} +26 -15
  117. package/src/rust/renderer/activate.rs +1 -0
  118. package/src/rust/renderer/limits.rs +9 -4
  119. package/src/rust/renderer/plugin_store.rs +12 -0
  120. package/src/rust/renderer/props.rs +28 -3
  121. package/src/rust/renderer/registry.rs +40 -15
  122. package/src/rust/renderer.rs +703 -60
  123. package/src/rust/session/column_defaults_update.rs +20 -28
  124. package/src/rust/session/drag_drop_update.rs +10 -10
  125. package/src/rust/session/metadata.rs +31 -16
  126. package/src/rust/session/props.rs +15 -6
  127. package/src/rust/session/view_subscription.rs +10 -0
  128. package/src/rust/session.rs +109 -147
  129. package/src/rust/tasks/copy_export.rs +178 -158
  130. package/src/rust/tasks/{structural.rs → dismiss_render_warning.rs} +20 -40
  131. package/src/rust/tasks/edit_expression.rs +68 -88
  132. package/src/rust/tasks/eject.rs +25 -22
  133. package/src/rust/tasks/intersection_observer.rs +8 -21
  134. package/src/rust/tasks/mod.rs +19 -21
  135. package/src/rust/tasks/reset_all.rs +98 -0
  136. package/src/rust/tasks/resize_observer.rs +11 -33
  137. package/src/rust/tasks/restore_and_render.rs +128 -90
  138. package/src/rust/tasks/{get_viewer_config.rs → send_column_config.rs} +39 -35
  139. package/src/rust/tasks/send_plugin_config.rs +33 -33
  140. package/src/rust/tasks/update_and_render.rs +75 -49
  141. package/src/rust/{components/containers/trap_door_panel.rs → tasks/update_theme.rs} +34 -33
  142. package/src/rust/tasks/validate_expression.rs +61 -0
  143. package/src/rust/utils/browser/selection.rs +4 -4
  144. package/src/rust/utils/mod.rs +0 -63
  145. package/src/svg/checkbox-checked-icon.svg +1 -1
  146. package/src/svg/checkbox-unchecked-icon.svg +1 -1
  147. package/src/svg/mega-menu-icons-density.svg +23 -0
  148. package/src/svg/mega-menu-icons-map-density.svg +24 -0
  149. package/src/svg/mega-menu-icons-map-line.svg +19 -0
  150. package/src/themes/botanical.css +27 -53
  151. package/src/themes/defaults.css +24 -36
  152. package/src/themes/dracula.css +36 -54
  153. package/src/themes/gruvbox-dark.css +39 -59
  154. package/src/themes/gruvbox.css +16 -28
  155. package/src/themes/icons.css +5 -0
  156. package/src/themes/intl/de.css +43 -6
  157. package/src/themes/intl/es.css +43 -6
  158. package/src/themes/intl/fr.css +43 -6
  159. package/src/themes/intl/ja.css +43 -6
  160. package/src/themes/intl/pt.css +43 -6
  161. package/src/themes/intl/zh.css +43 -6
  162. package/src/themes/intl.css +38 -4
  163. package/src/themes/monokai.css +45 -61
  164. package/src/themes/phosphor.css +20 -29
  165. package/src/themes/pro-dark.css +25 -34
  166. package/src/themes/solarized-dark.css +21 -36
  167. package/src/themes/solarized.css +13 -23
  168. package/src/themes/vaporwave.css +40 -74
  169. package/src/ts/bootstrap.ts +14 -3
  170. package/src/ts/column-format.ts +162 -0
  171. package/src/ts/extensions.ts +4 -0
  172. package/src/ts/perspective-viewer.ts +9 -1
  173. package/src/{rust/components/column_settings_sidebar/style_tab/stub.rs → ts/perspective-viewer.worker.ts} +2 -22
  174. package/src/ts/plugin.ts +25 -101
  175. package/src/ts/ts-rs/{FormatUnit.ts → ColumnSelectMode.ts} +1 -1
  176. package/src/ts/ts-rs/PluginStaticConfig.ts +78 -0
  177. package/src/ts/ts-rs/ViewerConfig.ts +1 -2
  178. package/src/ts/ts-rs/ViewerConfigUpdate.ts +2 -3
  179. package/dist/esm/ts-rs/ColumnConfigValues.d.ts +0 -31
  180. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +0 -1
  181. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +0 -15
  182. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +0 -18
  183. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +0 -1
  184. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +0 -6
  185. package/dist/esm/ts-rs/FormatMode.d.ts +0 -1
  186. package/dist/esm/ts-rs/FormatUnit.d.ts +0 -1
  187. package/dist/esm/ts-rs/NumberBackgroundMode.d.ts +0 -1
  188. package/dist/esm/ts-rs/NumberForegroundMode.d.ts +0 -1
  189. package/dist/esm/ts-rs/PluginConfig.d.ts +0 -2
  190. package/dist/esm/ts-rs/RoundingMode.d.ts +0 -1
  191. package/dist/esm/ts-rs/RoundingPriority.d.ts +0 -1
  192. package/dist/esm/ts-rs/SignDisplay.d.ts +0 -1
  193. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +0 -1
  194. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +0 -6
  195. package/dist/esm/ts-rs/StringColorMode.d.ts +0 -1
  196. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +0 -1
  197. package/dist/esm/ts-rs/UseGrouping.d.ts +0 -1
  198. package/src/rust/components/number_column_style.rs +0 -491
  199. package/src/rust/config/number_column_style.rs +0 -136
  200. package/src/rust/dragdrop.rs +0 -481
  201. package/src/rust/tasks/plugin_column_styles.rs +0 -98
  202. package/src/ts/ts-rs/ColumnConfigValues.ts +0 -14
  203. package/src/ts/ts-rs/CustomDatetimeFormat.ts +0 -3
  204. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +0 -5
  205. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +0 -8
  206. package/src/ts/ts-rs/DatetimeColorMode.ts +0 -3
  207. package/src/ts/ts-rs/DatetimeFormatType.ts +0 -8
  208. package/src/ts/ts-rs/FormatMode.ts +0 -3
  209. package/src/ts/ts-rs/NumberBackgroundMode.ts +0 -3
  210. package/src/ts/ts-rs/NumberForegroundMode.ts +0 -3
  211. package/src/ts/ts-rs/PluginConfig.ts +0 -4
  212. package/src/ts/ts-rs/RoundingMode.ts +0 -3
  213. package/src/ts/ts-rs/RoundingPriority.ts +0 -3
  214. package/src/ts/ts-rs/SignDisplay.ts +0 -3
  215. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +0 -3
  216. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +0 -4
  217. package/src/ts/ts-rs/StringColorMode.ts +0 -3
  218. package/src/ts/ts-rs/TrailingZeroDisplay.ts +0 -3
  219. package/src/ts/ts-rs/UseGrouping.ts +0 -3
  220. /package/dist/wasm/snippets/{perspective-viewer-d924246f0b4a3dce → perspective-viewer-3cd58f0374935772}/inline0.js +0 -0
  221. /package/dist/wasm/snippets/{perspective-viewer-d924246f0b4a3dce → perspective-viewer-3cd58f0374935772}/inline1.js +0 -0
  222. /package/dist/wasm/snippets/{perspective-viewer-d924246f0b4a3dce → perspective-viewer-3cd58f0374935772}/inline2.js +0 -0
  223. /package/dist/wasm/snippets/{perspective-viewer-d924246f0b4a3dce → perspective-viewer-3cd58f0374935772}/inline3.js +0 -0
  224. /package/dist/wasm/snippets/{perspective-viewer-d924246f0b4a3dce → perspective-viewer-3cd58f0374935772}/inline4.js +0 -0
  225. /package/src/rust/{tasks → config}/export_method.rs +0 -0
  226. /package/src/rust/{tasks → queries}/export_app.rs +0 -0
  227. /package/src/rust/{tasks → queries}/is_invalid_drop.rs +0 -0
@@ -24,7 +24,7 @@ mod props;
24
24
  mod registry;
25
25
  mod render_timer;
26
26
 
27
- use std::cell::{Ref, RefCell};
27
+ use std::cell::{Cell, RefCell};
28
28
  use std::collections::HashMap;
29
29
  use std::future::Future;
30
30
  use std::ops::Deref;
@@ -32,10 +32,11 @@ use std::pin::Pin;
32
32
  use std::rc::Rc;
33
33
 
34
34
  use futures::future::{join_all, select_all};
35
+ use perspective_client::config::ViewConfig;
35
36
  use perspective_client::utils::*;
36
37
  use perspective_client::{View, ViewWindow};
37
- use perspective_js::json;
38
- use perspective_js::utils::{ApiResult, ResultTApiErrorExt};
38
+ use perspective_js::utils::{ApiResult, JsValueSerdeExt, ResultTApiErrorExt};
39
+ use serde_json::Value;
39
40
  use wasm_bindgen::prelude::*;
40
41
  use web_sys::*;
41
42
  use yew::html::ImplicitClone;
@@ -50,9 +51,26 @@ pub use self::registry::*;
50
51
  use self::render_timer::*;
51
52
  use crate::config::*;
52
53
  use crate::js::plugin::*;
53
- use crate::presentation::ColumnConfigMap;
54
+ use crate::queries::resolve_abs_max;
55
+ use crate::session::Session;
54
56
  use crate::utils::*;
55
57
 
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>,
72
+ }
73
+
56
74
  /// Immutable state
57
75
  pub struct RendererData {
58
76
  plugin_data: RefCell<RendererMutData>,
@@ -60,22 +78,36 @@ pub struct RendererData {
60
78
  pub plugin_changed: PubSub<JsPerspectiveViewerPlugin>,
61
79
  pub style_changed: PubSub<()>,
62
80
  pub reset_changed: PubSub<()>,
81
+ pub selection_changed: PubSub<Option<ViewWindow>>,
82
+
83
+ /// Fires after a column-style edit lands in the active plugin's
84
+ /// bucket.
85
+ pub column_style_changed: PubSub<ColumnConfigMap>,
86
+
87
+ /// Fires after a plugin-level config edit lands in the active
88
+ /// plugin's bucket.
89
+ pub plugin_config_changed: PubSub<serde_json::Map<String, serde_json::Value>>,
90
+
91
+ /// `true` while the active plugin's "rendering N of M" warning is
92
+ /// dismissable.
93
+ pub render_warning: Cell<bool>,
63
94
 
64
- /// Injected callback from the root component, replacing the former
65
- /// `render_limits_changed: PubSub` field. Fires after every draw/update
66
- /// with the computed render limits.
95
+ /// Fires after every draw/update with the computed render limits.
67
96
  pub on_render_limits_changed: RefCell<Option<Callback<RenderLimits>>>,
68
97
  }
69
98
 
70
99
  /// Mutable state
71
100
  pub struct RendererMutData {
72
101
  viewer_elem: HtmlElement,
73
- metadata: ViewConfigRequirements,
102
+ metadata: Rc<PluginStaticConfig>,
74
103
  plugin_store: PluginStore,
75
104
  plugins_idx: Option<usize>,
76
105
  timer: MovingWindowRenderTimer,
77
106
  selection: Option<ViewWindow>,
78
107
  pending_plugin: Option<usize>,
108
+
109
+ /// Per-plugin config buckets, keyed by plugin name.
110
+ plugin_states: HashMap<String, PluginScopedConfig>,
79
111
  }
80
112
 
81
113
  /// The state object responsible for the active [`JsPerspectiveViewerPlugin`].
@@ -117,30 +149,26 @@ impl Renderer {
117
149
  Self(Rc::new(RendererData {
118
150
  plugin_data: RefCell::new(RendererMutData {
119
151
  viewer_elem: viewer_elem.clone(),
120
- metadata: ViewConfigRequirements::default(),
152
+ metadata: Rc::new(PluginStaticConfig::default()),
121
153
  plugin_store: PluginStore::default(),
122
154
  plugins_idx: None,
123
155
  selection: None,
124
156
  timer: MovingWindowRenderTimer::default(),
125
157
  pending_plugin: None,
158
+ plugin_states: HashMap::default(),
126
159
  }),
127
160
  draw_lock: Default::default(),
128
161
  plugin_changed: Default::default(),
129
162
  style_changed: Default::default(),
130
163
  reset_changed: Default::default(),
164
+ selection_changed: Default::default(),
165
+ column_style_changed: Default::default(),
166
+ plugin_config_changed: Default::default(),
167
+ render_warning: Cell::new(true),
131
168
  on_render_limits_changed: Default::default(),
132
169
  }))
133
170
  }
134
171
 
135
- pub async fn reset(&self, columns_config: Option<&ColumnConfigMap>) -> ApiResult<()> {
136
- self.0.borrow_mut().plugins_idx = None;
137
- if let Ok(plugin) = self.get_active_plugin() {
138
- plugin.restore(&json!({}), columns_config)?;
139
- }
140
-
141
- Ok(())
142
- }
143
-
144
172
  pub fn delete(&self) -> ApiResult<()> {
145
173
  self.get_active_plugin().map(|x| x.delete()).unwrap_or_log();
146
174
  self.plugin_data.borrow().viewer_elem.set_inner_text("");
@@ -153,13 +181,505 @@ impl Renderer {
153
181
  Ok(())
154
182
  }
155
183
 
156
- pub fn metadata(&self) -> Ref<'_, ViewConfigRequirements> {
157
- Ref::map(self.borrow(), |x| &x.metadata)
184
+ pub fn metadata(&self) -> Rc<PluginStaticConfig> {
185
+ self.borrow().metadata.clone()
158
186
  }
159
187
 
160
188
  pub fn is_chart(&self) -> bool {
161
- let plugin = self.get_active_plugin().unwrap();
162
- plugin.name().as_str() != "Datagrid"
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
195
+ }
196
+
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())
202
+ }
203
+
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
+ }
324
+ }
325
+
326
+ configs
327
+ }
328
+
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
+ }
339
+ }
340
+
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()
354
+ }
355
+
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
+ }
421
+
422
+ changed
423
+ },
424
+ }
425
+ }
426
+
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.
431
+ ///
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
+ }
460
+
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
+ }
476
+
477
+ // ─── Plugin-level config (active plugin's bucket) ─────────────────
478
+
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())
487
+ })
488
+ .unwrap_or_default()
489
+ }
490
+
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();
500
+ }
501
+ }
502
+
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())
515
+ }
516
+
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())
574
+ }
575
+
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
+ }
638
+ }
639
+
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;
662
+
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
+ }
673
+
674
+ changed
675
+ }
676
+
677
+ /// Whether the active plugin's render warning is currently armed
678
+ /// (i.e. an oversized view will be capped). Becomes `false` once
679
+ /// the user clicks "Render all points"; resets to `true` on the
680
+ /// next plugin change.
681
+ pub fn is_render_warning_enabled(&self) -> bool {
682
+ self.0.render_warning.get()
163
683
  }
164
684
 
165
685
  /// Return all plugin instances, whether they are active or not. Useful
@@ -173,6 +693,13 @@ impl Renderer {
173
693
  self.0.borrow_mut().plugin_store.plugin_records().clone()
174
694
  }
175
695
 
696
+ /// Cached `PluginStaticConfig`s for every registered plugin, in
697
+ /// registration (priority) order. Mirrors `get_all_plugins()`
698
+ /// element-for-element.
699
+ pub fn get_all_plugin_configs(&self) -> Vec<Rc<PluginStaticConfig>> {
700
+ self.0.borrow_mut().plugin_store.plugin_configs().clone()
701
+ }
702
+
176
703
  /// Gets the currently active plugin. Calling this method before a plugin
177
704
  /// has been selected will cause the default (first) plugin to be
178
705
  /// selected, and doing so when no plugins have been registered is an
@@ -206,16 +733,17 @@ impl Renderer {
206
733
  }
207
734
 
208
735
  pub async fn restyle_all(&self, view: &perspective_client::View) -> ApiResult<JsValue> {
209
- let plugins = self.get_all_plugins();
210
- let tasks = plugins
211
- .iter()
212
- .map(|plugin| plugin.restyle(view.clone().into()));
213
-
214
- join_all(tasks)
215
- .await
216
- .into_iter()
217
- .collect::<Result<Vec<_>, _>>()
218
- .map(|_| JsValue::UNDEFINED)
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)
219
747
  }
220
748
 
221
749
  pub fn set_throttle(&self, val: Option<f64>) {
@@ -223,7 +751,12 @@ impl Renderer {
223
751
  }
224
752
 
225
753
  pub fn set_selection(&self, window: Option<ViewWindow>) {
226
- self.borrow_mut().selection = window
754
+ if self.borrow().selection == window {
755
+ return;
756
+ }
757
+
758
+ self.borrow_mut().selection = window.clone();
759
+ self.selection_changed.emit(window);
227
760
  }
228
761
 
229
762
  pub fn get_selection(&self) -> Option<ViewWindow> {
@@ -231,14 +764,13 @@ impl Renderer {
231
764
  }
232
765
 
233
766
  pub fn disable_active_plugin_render_warning(&self) {
234
- self.borrow_mut().metadata.render_warning = false;
235
- self.get_active_plugin().unwrap().set_render_warning(false);
767
+ self.0.render_warning.set(false);
236
768
  }
237
769
 
238
770
  pub fn get_next_plugin_metadata(
239
771
  &self,
240
772
  update: &PluginUpdate,
241
- ) -> Option<ViewConfigRequirements> {
773
+ ) -> Option<Rc<PluginStaticConfig>> {
242
774
  let default_plugin_name = PLUGIN_REGISTRY.default_plugin_name();
243
775
  let name = match update {
244
776
  PluginUpdate::Missing => return None,
@@ -254,9 +786,12 @@ impl Renderer {
254
786
 
255
787
  if changed {
256
788
  self.borrow_mut().pending_plugin = Some(idx);
257
- self.get_plugin(name)
258
- .and_then(|x| x.get_requirements())
259
- .ok()
789
+ self.0
790
+ .borrow_mut()
791
+ .plugin_store
792
+ .plugin_configs()
793
+ .get(idx)
794
+ .cloned()
260
795
  } else {
261
796
  None
262
797
  }
@@ -271,10 +806,7 @@ impl Renderer {
271
806
  );
272
807
 
273
808
  if changed {
274
- self.borrow_mut().plugins_idx = Some(idx);
275
- let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
276
- self.borrow_mut().metadata = plugin.get_requirements()?;
277
- self.plugin_changed.emit(plugin);
809
+ self.commit_plugin_idx(idx)?;
278
810
  }
279
811
 
280
812
  Ok(changed)
@@ -301,15 +833,49 @@ impl Renderer {
301
833
  );
302
834
 
303
835
  if changed {
304
- self.borrow_mut().plugins_idx = Some(idx);
305
- let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
306
- self.borrow_mut().metadata = plugin.get_requirements()?;
307
- self.plugin_changed.emit(plugin);
836
+ self.commit_plugin_idx(idx)?;
308
837
  }
309
838
 
310
839
  Ok(changed)
311
840
  }
312
841
 
842
+ /// Shared tail of `apply_pending_plugin` / `set_plugin`: switch the
843
+ /// active plugin to `idx`, swap in its cached `PluginStaticConfig`,
844
+ /// reset the per-plugin render-warning flag, and fire
845
+ /// `plugin_changed`.
846
+ fn commit_plugin_idx(&self, idx: usize) -> ApiResult<()> {
847
+ self.borrow_mut().plugins_idx = Some(idx);
848
+ let config = self
849
+ .0
850
+ .borrow_mut()
851
+ .plugin_store
852
+ .plugin_configs()
853
+ .get(idx)
854
+ .cloned()
855
+ .ok_or("No Plugin")?;
856
+
857
+ self.borrow_mut().metadata = config.clone();
858
+ self.0.render_warning.set(true);
859
+ let plugin: JsPerspectiveViewerPlugin = self.get_active_plugin()?;
860
+
861
+ // Push the newly-activated plugin's stored bucket through
862
+ // `plugin.restore` so the swap immediately reflects any
863
+ // viewer-owned per-column and plugin-level config.
864
+ let bucket = self
865
+ .borrow()
866
+ .plugin_states
867
+ .get(&config.name)
868
+ .cloned()
869
+ .unwrap_or_default();
870
+ let token = JsValue::from_serde_ext(&bucket.plugin).unwrap_or(JsValue::NULL);
871
+ if let Err(e) = plugin.restore(&token, Some(&bucket.columns)) {
872
+ tracing::warn!("plugin.restore on swap failed: {:?}", e);
873
+ }
874
+
875
+ self.plugin_changed.emit(plugin);
876
+ Ok(())
877
+ }
878
+
313
879
  pub async fn with_lock<T>(self, task: impl Future<Output = ApiResult<T>>) -> ApiResult<T> {
314
880
  let draw_mutex = self.draw_lock();
315
881
  draw_mutex.lock(task).await
@@ -397,8 +963,9 @@ impl Renderer {
397
963
 
398
964
  async fn draw_view(&self, view: &perspective_client::View, is_update: bool) -> ApiResult<()> {
399
965
  let plugin = self.get_active_plugin()?;
400
- let meta = self.metadata().clone();
401
- let mut limits = get_row_and_col_limits(view, &meta).await?;
966
+ let meta = self.metadata();
967
+ let mut limits =
968
+ get_row_and_col_limits(view, &meta, self.is_render_warning_enabled()).await?;
402
969
  limits.is_update = is_update;
403
970
  if let Some(cb) = self.0.on_render_limits_changed.borrow().as_ref() {
404
971
  cb.emit(limits);
@@ -501,12 +1068,20 @@ impl Renderer {
501
1068
 
502
1069
  fn find_plugin_idx(&self, name: &str) -> Option<usize> {
503
1070
  let short_name = make_short_name(name);
504
- self.0
505
- .borrow_mut()
506
- .plugin_store
507
- .plugins()
1071
+ let mut borrowed = self.0.borrow_mut();
1072
+ 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
+ let short_names: Vec<String> = configs.iter().map(|c| make_short_name(&c.name)).collect();
1078
+ if let Some(i) = short_names.iter().position(|n| n == &short_name) {
1079
+ return Some(i);
1080
+ }
1081
+
1082
+ short_names
508
1083
  .iter()
509
- .position(|elem| make_short_name(&elem.name()).contains(&short_name))
1084
+ .position(|n: &String| n.contains(&short_name))
510
1085
  }
511
1086
  }
512
1087
 
@@ -531,28 +1106,96 @@ impl Renderer {
531
1106
  // later will never be found.
532
1107
  let has_plugin = self.0.borrow().plugins_idx.is_some();
533
1108
  if has_plugin {
534
- let plugin_name = self.get_active_plugin().ok().map(|p| p.name());
535
- let requirements = self.metadata().clone();
1109
+ let config = self.metadata();
1110
+ let plugin_name = Some(config.name.clone());
1111
+ let is_chart = config.name.as_str() != "Datagrid";
536
1112
  let available_plugins = self
537
- .get_all_plugins()
1113
+ .get_all_plugin_configs()
538
1114
  .into_iter()
539
- .map(|p| p.name())
1115
+ .map(|c| c.name.clone())
540
1116
  .collect::<Vec<_>>()
541
1117
  .into();
1118
+ let plugin_config = PtrEqRc::new(self.get_plugin_config());
542
1119
 
543
1120
  RendererProps {
544
1121
  plugin_name,
545
- requirements,
1122
+ config,
546
1123
  render_limits,
547
1124
  available_plugins,
1125
+ is_chart,
1126
+ plugin_config,
548
1127
  }
549
1128
  } else {
550
1129
  RendererProps {
551
1130
  plugin_name: None,
552
- requirements: ViewConfigRequirements::default(),
1131
+ config: Rc::new(PluginStaticConfig::default()),
553
1132
  render_limits,
554
1133
  available_plugins: PtrEqRc::new(vec![]),
1134
+ is_chart: false,
1135
+ plugin_config: PtrEqRc::default(),
555
1136
  }
556
1137
  }
557
1138
  }
558
1139
  }
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
+ }