@perspective-dev/viewer 5.2.0 → 5.3.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 (179) hide show
  1. package/dist/cdn/perspective-viewer.js +8 -8
  2. package/dist/cdn/perspective-viewer.js.map +3 -3
  3. package/dist/css/blueprint.css +1 -0
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -0
  7. package/dist/css/gruvbox-dark.css +1 -1
  8. package/dist/css/gruvbox.css +1 -1
  9. package/dist/css/icons.css +1 -1
  10. package/dist/css/intl/de.css +1 -1
  11. package/dist/css/intl/es.css +1 -1
  12. package/dist/css/intl/fr.css +1 -1
  13. package/dist/css/intl/ja.css +1 -1
  14. package/dist/css/intl/pt.css +1 -1
  15. package/dist/css/intl/zh.css +1 -1
  16. package/dist/css/intl.css +1 -1
  17. package/dist/css/ledger.css +1 -0
  18. package/dist/css/monokai.css +1 -1
  19. package/dist/css/nord.css +1 -0
  20. package/dist/css/phosphor.css +1 -1
  21. package/dist/css/pro-dark.css +1 -1
  22. package/dist/css/pro.css +1 -1
  23. package/dist/css/solarized-dark.css +1 -1
  24. package/dist/css/solarized.css +1 -1
  25. package/dist/css/themes.css +1 -1
  26. package/dist/css/vaporwave.css +1 -1
  27. package/dist/css/velvet.css +1 -0
  28. package/dist/docs/perspective-docs.json +1 -1
  29. package/dist/esm/column-format.d.ts +16 -3
  30. package/dist/esm/perspective-viewer.d.ts +1 -0
  31. package/dist/esm/perspective-viewer.inline.js +9 -9
  32. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  33. package/dist/esm/perspective-viewer.js +8 -8
  34. package/dist/esm/perspective-viewer.js.map +3 -3
  35. package/dist/esm/plugin.d.ts +10 -0
  36. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  37. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  38. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  39. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  40. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  41. package/dist/wasm/perspective-viewer.d.ts +43 -36
  42. package/dist/wasm/perspective-viewer.js +198 -136
  43. package/dist/wasm/perspective-viewer.wasm +0 -0
  44. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  45. package/package.json +1 -1
  46. package/src/css/column-settings-panel.css +28 -5
  47. package/src/css/column-style.css +291 -31
  48. package/src/css/containers/tabs.css +13 -3
  49. package/src/css/dom/select.css +21 -9
  50. package/src/css/form/code-editor.css +4 -4
  51. package/src/css/plugin-selector.css +1 -0
  52. package/src/css/plugin-settings-panel.css +72 -0
  53. package/src/css/viewer.css +0 -5
  54. package/src/rust/agent/tools.rs +53 -19
  55. package/src/rust/components/column_selector/active_column.rs +2 -2
  56. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  57. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  58. package/src/rust/components/column_selector/config_selector.rs +8 -0
  59. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  60. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  61. package/src/rust/components/column_selector.rs +48 -4
  62. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  63. package/src/rust/components/column_settings_sidebar/style_tab.rs +330 -234
  64. package/src/rust/components/column_settings_sidebar.rs +88 -69
  65. package/src/rust/components/containers/control_group.rs +91 -0
  66. package/src/rust/components/containers/mod.rs +1 -0
  67. package/src/rust/components/containers/select.rs +8 -2
  68. package/src/rust/components/datetime_column_style/simple.rs +15 -4
  69. package/src/rust/components/datetime_column_style.rs +50 -10
  70. package/src/rust/components/editable_header.rs +85 -144
  71. package/src/rust/components/expression_editor.rs +9 -16
  72. package/src/rust/components/form/code_editor.rs +16 -6
  73. package/src/rust/components/form/debug.rs +1 -0
  74. package/src/rust/components/form/mod.rs +3 -1
  75. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  76. package/src/rust/components/form/named_value_picker.rs +86 -0
  77. package/src/rust/components/form/palette_selector.rs +440 -0
  78. package/src/rust/components/main_panel/msg.rs +2 -2
  79. package/src/rust/components/main_panel/reconcile.rs +4 -4
  80. package/src/rust/components/plugin_tab.rs +126 -112
  81. package/src/rust/components/settings_panel.rs +26 -20
  82. package/src/rust/components/status_bar.rs +0 -1
  83. package/src/rust/components/style_controls/number_string_format/digits_section.rs +22 -10
  84. package/src/rust/components/style_controls/number_string_format/misc_section.rs +6 -2
  85. package/src/rust/components/style_controls/number_string_format/style_section.rs +1 -0
  86. package/src/rust/components/style_controls/number_string_format/types.rs +24 -0
  87. package/src/rust/components/style_controls/number_string_format.rs +37 -14
  88. package/src/rust/components/viewer/msg.rs +46 -7
  89. package/src/rust/components/viewer/panels.rs +11 -9
  90. package/src/rust/components/viewer/render.rs +8 -3
  91. package/src/rust/components/viewer/settings.rs +163 -23
  92. package/src/rust/components/viewer/snapshots.rs +46 -21
  93. package/src/rust/components/viewer/wiring.rs +14 -25
  94. package/src/rust/components/viewer.rs +12 -8
  95. package/src/rust/config/column_config_schema.rs +435 -45
  96. package/src/rust/config/css_scale.rs +882 -0
  97. package/src/rust/config/datetime_column_style.rs +4 -21
  98. package/src/rust/config/mod.rs +2 -0
  99. package/src/rust/config/number_string_format.rs +211 -18
  100. package/src/rust/config/options.rs +10 -0
  101. package/src/rust/config/viewer_config.rs +49 -55
  102. package/src/rust/config/workspace_config.rs +132 -5
  103. package/src/rust/custom_elements/viewer.rs +214 -155
  104. package/src/rust/custom_events.rs +14 -1
  105. package/src/rust/js/regular_layout.rs +3 -3
  106. package/src/rust/lib.rs +2 -0
  107. package/src/rust/presentation/column_locator.rs +19 -5
  108. package/src/rust/presentation.rs +209 -36
  109. package/src/rust/queries/column_locator.rs +74 -44
  110. package/src/rust/queries/get_viewer_config.rs +0 -3
  111. package/src/rust/queries/mod.rs +2 -0
  112. package/src/rust/queries/palette_set.rs +82 -0
  113. package/src/rust/queries/plugin_column_styles.rs +6 -2
  114. package/src/rust/renderer/dispatch.rs +50 -12
  115. package/src/rust/renderer/plugin_config.rs +362 -53
  116. package/src/rust/renderer.rs +15 -42
  117. package/src/rust/session/column_defaults_update.rs +15 -8
  118. package/src/rust/session/metadata.rs +5 -1
  119. package/src/rust/session/props.rs +1 -0
  120. package/src/rust/session.rs +9 -6
  121. package/src/rust/tasks/create_panel.rs +44 -57
  122. package/src/rust/tasks/edit_expression.rs +19 -23
  123. package/src/rust/tasks/edit_window.rs +8 -10
  124. package/src/rust/tasks/eject.rs +4 -1
  125. package/src/rust/tasks/pipeline.rs +2 -2
  126. package/src/rust/tasks/presize_panels.rs +44 -5
  127. package/src/rust/tasks/reset_all.rs +4 -4
  128. package/src/rust/tasks/restore_and_render.rs +17 -44
  129. package/src/rust/tasks/restore_panel.rs +11 -13
  130. package/src/rust/tasks/set_edit_mode.rs +3 -1
  131. package/src/rust/tasks/sync_update_panels.rs +11 -1
  132. package/src/rust/tasks/update_theme.rs +23 -18
  133. package/src/rust/utils/css_vars.rs +26 -0
  134. package/src/rust/utils/mod.rs +2 -0
  135. package/src/rust/utils/spawn.rs +1 -1
  136. package/src/rust/workspace.rs +435 -272
  137. package/src/svg/lock.svg +7 -0
  138. package/src/themes/blueprint.css +190 -0
  139. package/src/themes/botanical.css +14 -17
  140. package/src/themes/defaults.css +4 -110
  141. package/src/themes/dracula.css +19 -14
  142. package/src/themes/eggplant.css +189 -0
  143. package/src/themes/gruvbox-dark.css +23 -15
  144. package/src/themes/gruvbox.css +11 -1
  145. package/src/themes/icons.css +2 -2
  146. package/src/themes/intl/de.css +23 -1
  147. package/src/themes/intl/es.css +23 -1
  148. package/src/themes/intl/fr.css +23 -1
  149. package/src/themes/intl/ja.css +23 -1
  150. package/src/themes/intl/pt.css +23 -1
  151. package/src/themes/intl/zh.css +23 -1
  152. package/src/themes/intl.css +26 -4
  153. package/src/themes/ledger.css +195 -0
  154. package/src/themes/monokai.css +24 -14
  155. package/src/themes/nord.css +186 -0
  156. package/src/themes/phosphor.css +7 -14
  157. package/src/themes/pro-dark.css +12 -16
  158. package/src/themes/pro.css +118 -0
  159. package/src/themes/solarized-dark.css +9 -16
  160. package/src/themes/solarized.css +7 -0
  161. package/src/themes/themes.css +10 -5
  162. package/src/themes/vaporwave.css +22 -14
  163. package/src/themes/velvet.css +192 -0
  164. package/src/ts/column-format.ts +52 -2
  165. package/src/ts/perspective-viewer.ts +1 -0
  166. package/src/ts/plugin.ts +14 -0
  167. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  168. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  169. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  170. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  171. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  172. package/src/rust/components/form/color_range_selector.rs +0 -137
  173. package/src/svg/dropdown-selector-light.svg +0 -3
  174. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline0.js +0 -0
  175. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline1.js +0 -0
  176. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline2.js +0 -0
  177. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline3.js +0 -0
  178. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline4.js +0 -0
  179. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline5.js +0 -0
@@ -66,6 +66,23 @@ impl ColumnLocator {
66
66
  }
67
67
  }
68
68
 
69
+ /// What the column-settings drawer is opened on, as held in presentation
70
+ /// state: a column by name, or the not-yet-saved expression.
71
+ #[derive(Clone, Debug, PartialEq)]
72
+ pub enum ColumnSettingsTarget {
73
+ Column(String),
74
+ NewExpression,
75
+ }
76
+
77
+ impl ColumnSettingsTarget {
78
+ pub fn name(&self) -> Option<&String> {
79
+ match self {
80
+ Self::Column(name) => Some(name),
81
+ Self::NewExpression => None,
82
+ }
83
+ }
84
+ }
85
+
69
86
  #[derive(Debug, Default, Clone, Copy, PartialEq)]
70
87
 
71
88
  pub enum ColumnSettingsTab {
@@ -83,16 +100,13 @@ impl Display for ColumnSettingsTab {
83
100
 
84
101
  #[derive(Clone, Default, Debug, PartialEq)]
85
102
  pub struct OpenColumnSettings {
86
- pub locator: Option<ColumnLocator>,
103
+ pub target: Option<ColumnSettingsTarget>,
87
104
  pub tab: Option<ColumnSettingsTab>,
88
105
  }
89
106
 
90
107
  impl OpenColumnSettings {
91
108
  pub fn name(&self) -> Option<String> {
92
- self.locator
93
- .as_ref()
94
- .and_then(|l| l.name())
95
- .map(|s| s.to_owned())
109
+ self.target.as_ref().and_then(|t| t.name()).cloned()
96
110
  }
97
111
  }
98
112
 
@@ -15,8 +15,8 @@ pub mod drag_helpers;
15
15
  mod props;
16
16
  mod sheets;
17
17
 
18
- use std::cell::RefCell;
19
- use std::collections::HashSet;
18
+ use std::cell::{Cell, RefCell};
19
+ use std::collections::{BTreeMap, HashSet};
20
20
  use std::ops::Deref;
21
21
  use std::rc::Rc;
22
22
 
@@ -27,10 +27,13 @@ use web_sys::*;
27
27
  use yew::html::ImplicitClone;
28
28
  use yew::prelude::*;
29
29
 
30
- pub use self::column_locator::{ColumnLocator, ColumnSettingsTab, ColumnTab, OpenColumnSettings};
30
+ pub use self::column_locator::{
31
+ ColumnLocator, ColumnSettingsTab, ColumnSettingsTarget, ColumnTab, OpenColumnSettings,
32
+ };
31
33
  use self::drag_helpers::DragTargetState;
32
34
  pub use self::drag_helpers::{DragDropContainer, DragEndCallback};
33
35
  pub use self::props::{DragDropProps, PresentationProps};
36
+ use crate::config::{CssKind, NamedValue, assign_palette_names};
34
37
  use crate::utils::*;
35
38
 
36
39
  #[derive(Clone, Debug)]
@@ -85,10 +88,24 @@ pub struct PresentationHandle {
85
88
  /// concurrent `get_available_themes` calls await one parse instead of
86
89
  /// racing their own.
87
90
  theme_init: Mutex<()>,
91
+
92
+ /// Whether the host's theme was ever EXPLICITLY chosen — authored as a
93
+ /// `theme` attribute, or set by name through [`Self::set_theme_name`].
94
+ ///
95
+ /// `false` means the host merely displays the registry default, which
96
+ /// [`Self::reset_themes`] is free to move; `true` pins the selection
97
+ /// until it leaves the registry. The `theme` attribute alone cannot
98
+ /// carry this, because `init` stamps it unconditionally so the document
99
+ /// cascade has a theme to match.
100
+ theme_selected: Cell<bool>,
101
+
102
+ palette: RefCell<BTreeMap<String, String>>,
88
103
  is_settings_open: RefCell<bool>,
89
104
  open_column_settings: RefCell<OpenColumnSettings>,
90
105
  is_workspace: RefCell<Option<bool>>,
91
106
 
107
+ collapsed_control_groups: RefCell<HashSet<String>>,
108
+
92
109
  /// Drag/drop in-progress state. Empty (`NoDrag`) when no user drag is
93
110
  /// active. Mutated by `notify_drag_*` / `notify_drop`; read by component
94
111
  /// CSS-class derivations (`is_dragover`, `get_drag_column`).
@@ -109,11 +126,20 @@ pub struct PresentationHandle {
109
126
  /// dragged element from the shadow tree.
110
127
  host_dragend: RefCell<Option<DragEndCallback>>,
111
128
 
129
+ source_dragend: RefCell<Option<(web_sys::EventTarget, DragEndCallback)>>,
130
+
112
131
  /// IntersectionObserver-based fallback for the drag image, kept alive for
113
132
  /// the duration of the drag.
114
133
  drag_target: RefCell<Option<DragTargetState>>,
115
134
 
116
- pub settings_open_changed: PubSub<bool>,
135
+ /// `(open, announce)` — `announce` says this toggle is the SOLE carrier
136
+ /// of the config change (a user gesture: toolbar, `toggleConfig`), so
137
+ /// the element must emit `toggle-settings` + a config-update for it. An
138
+ /// API restore's toggle is announced by its own view-config commit
139
+ /// dispatch instead — announcing here too would double-emit, the first
140
+ /// carrying the intermediate config (settings flipped, view fields not
141
+ /// yet committed).
142
+ pub settings_open_changed: PubSub<(bool, bool)>,
117
143
 
118
144
  /// Injected callback from the root component, replacing the former
119
145
  /// `is_workspace_changed: PubSub` field.
@@ -158,6 +184,8 @@ impl Presentation {
158
184
  agent: Default::default(),
159
185
  themes: Default::default(),
160
186
  theme_init: Default::default(),
187
+ theme_selected: Cell::new(elem.get_attribute("theme").is_some()),
188
+ palette: Default::default(),
161
189
  is_workspace: Default::default(),
162
190
  settings_open_changed: Default::default(),
163
191
  settings_before_open_changed: Default::default(),
@@ -165,6 +193,7 @@ impl Presentation {
165
193
  on_is_workspace_changed: Default::default(),
166
194
  is_settings_open: Default::default(),
167
195
  open_column_settings: Default::default(),
196
+ collapsed_control_groups: Default::default(),
168
197
  theme_config_updated: PubSub::default(),
169
198
  on_eject: PubSub::default(),
170
199
  statusbar_pointer_event: PubSub::default(),
@@ -173,6 +202,7 @@ impl Presentation {
173
202
  on_dragstart: Default::default(),
174
203
  on_dragend: Default::default(),
175
204
  host_dragend: Default::default(),
205
+ source_dragend: Default::default(),
176
206
  drag_target: Default::default(),
177
207
  }));
178
208
 
@@ -238,13 +268,84 @@ impl Presentation {
238
268
  }
239
269
  }
240
270
 
241
- pub fn set_settings_open(&self, open: bool) {
242
- self.settings_open_changed.emit(open);
271
+ /// See [`PresentationHandle::settings_open_changed`] for `announce`.
272
+ pub fn set_settings_open(&self, open: bool, announce: bool) {
273
+ self.settings_open_changed.emit((open, announce));
243
274
  }
244
275
 
245
276
  /// Sets the currently opened column settings. Emits an internal event on
246
277
  /// change. Passing None is a shorthand for setting all fields to
247
278
  /// None.
279
+ /// The workspace palette as last restored (canonical values).
280
+ pub fn palette(&self) -> BTreeMap<String, String> {
281
+ self.0.palette.borrow().clone()
282
+ }
283
+
284
+ /// Replace the host palette: every previously-applied `--psp-user--*`
285
+ /// inline property is removed, then `palette` is applied.
286
+ pub fn set_palette(&self, palette: BTreeMap<String, String>) -> ApiResult<()> {
287
+ let style = self.0.viewer_elem.style();
288
+ for name in self.0.palette.borrow().keys() {
289
+ style.remove_property(name)?;
290
+ }
291
+
292
+ for (name, value) in &palette {
293
+ style.set_property(name, value)?;
294
+ }
295
+
296
+ *self.0.palette.borrow_mut() = palette;
297
+ Ok(())
298
+ }
299
+
300
+ /// Pin `literal` into the restored palette, named by the same rules
301
+ /// as the derived set and applied to the host.
302
+ pub fn pin_style(&self, kind: CssKind, literal: &str) -> ApiResult<()> {
303
+ let Ok(value) = kind.canonicalize(literal) else {
304
+ return Ok(());
305
+ };
306
+
307
+ let current = self.palette();
308
+ let host = self.host_named_values(kind);
309
+ let set = assign_palette_names(&current, &host, &[(kind, value)], &|name| {
310
+ self.resolve_css_var(name).is_some()
311
+ });
312
+
313
+ let style = self.0.viewer_elem.style();
314
+ for (name, value) in &set {
315
+ if !current.contains_key(name) {
316
+ style.set_property(name, value)?;
317
+ }
318
+ }
319
+
320
+ *self.0.palette.borrow_mut() = set;
321
+ Ok(())
322
+ }
323
+
324
+ /// The host's computed value for custom property `name` (inline
325
+ /// palette, then theme/page stylesheets), or `None` if undefined.
326
+ pub fn resolve_css_var(&self, name: &str) -> Option<String> {
327
+ read_custom_property(&self.0.viewer_elem, name)
328
+ }
329
+
330
+ /// Theme/page-authored named values of `kind` on the host, canonical,
331
+ /// discovered by the contiguous-numbering walk `--psp-user--<kind>-1`,
332
+ /// `-2`, … up to the first undefined name.
333
+ pub fn host_named_values(&self, kind: CssKind) -> Vec<NamedValue> {
334
+ let mut out = vec![];
335
+ for n in 1.. {
336
+ let name = format!("{}{n}", kind.var_prefix());
337
+ let Some(raw) = self.resolve_css_var(&name) else {
338
+ break;
339
+ };
340
+
341
+ if let Ok(value) = kind.canonicalize(&raw) {
342
+ out.push(NamedValue { name, value });
343
+ }
344
+ }
345
+
346
+ out
347
+ }
348
+
248
349
  pub fn set_open_column_settings(&self, settings: Option<OpenColumnSettings>) {
249
350
  let settings = settings.unwrap_or_default();
250
351
  if *(self.open_column_settings.borrow()) != settings {
@@ -259,6 +360,20 @@ impl Presentation {
259
360
  self.open_column_settings.borrow().deref().clone()
260
361
  }
261
362
 
363
+ pub fn is_control_group_collapsed(&self, key: &str) -> bool {
364
+ self.collapsed_control_groups.borrow().contains(key)
365
+ }
366
+
367
+ pub fn set_control_group_collapsed(&self, key: &str, collapsed: bool) {
368
+ if collapsed {
369
+ self.collapsed_control_groups
370
+ .borrow_mut()
371
+ .insert(key.to_owned());
372
+ } else {
373
+ self.collapsed_control_groups.borrow_mut().remove(key);
374
+ }
375
+ }
376
+
262
377
  async fn init(self) -> ApiResult<()> {
263
378
  self.set_theme_attribute(self.get_selected_theme_name().await.as_deref())
264
379
  }
@@ -315,22 +430,24 @@ impl Presentation {
315
430
  index.and_then(|x| themes.get(x).cloned())
316
431
  }
317
432
 
318
- /// The default theme (first registered), used to resolve a panel's
319
- /// effective theme when it has no per-panel override. `None` if no
433
+ /// The theme a NEW panel is born with: the host's if it has one, else
434
+ /// the registry default. Synchronous, because panel creation is — the
435
+ /// registry fallback is `None` until the registry first parses, which
436
+ /// [`crate::tasks::seed_panel_theme`] fills in.
437
+ pub fn active_theme_name_sync(&self) -> Option<String> {
438
+ self.0
439
+ .viewer_elem
440
+ .get_attribute("theme")
441
+ .or_else(|| self.0.themes.borrow().as_ref()?.first().cloned())
442
+ }
443
+
444
+ /// The registry default — the FIRST registered theme, which a panel or
445
+ /// host resolves to only when it has no theme of its own. `None` if no
320
446
  /// themes exist.
321
447
  pub async fn get_default_theme_name(&self) -> Option<String> {
322
448
  self.get_available_themes().await.ok()?.first().cloned()
323
449
  }
324
450
 
325
- /// SYNC read of the registry default theme name, derived on demand from
326
- /// the memoized registry — `None` until the registry first parses (or
327
- /// when no themes exist). For synchronous stamping paths that must not
328
- /// await registry init; prefer [`Self::get_default_theme_name`] where
329
- /// awaiting is acceptable.
330
- pub fn default_theme_name_sync(&self) -> Option<String> {
331
- self.0.themes.borrow().as_ref()?.first().cloned()
332
- }
333
-
334
451
  fn set_theme_attribute(&self, theme: Option<&str>) -> ApiResult<()> {
335
452
  if let Some(theme) = theme {
336
453
  Ok(self.0.viewer_elem.set_attribute("theme", theme)?)
@@ -341,12 +458,39 @@ impl Presentation {
341
458
 
342
459
  pub async fn reset_theme(&self) -> ApiResult<()> {
343
460
  *self.0.is_workspace.borrow_mut() = None;
344
- let themes = self.get_available_themes().await?;
345
- let default_theme = themes.first().map(|x| x.as_str());
346
- self.set_theme_name(default_theme).await?;
461
+ self.set_theme_name(None).await?;
347
462
  Ok(())
348
463
  }
349
464
 
465
+ /// Adopt `themes` as the available set, KEEPING the host's theme unless
466
+ /// it was never explicitly chosen or has left the set — the only two
467
+ /// cases in which re-ordering the registry may move the viewer.
468
+ ///
469
+ /// Always re-stamps; the caller publishes ([`Self::publish_theme_config`])
470
+ /// once its restyles have resolved, because the available list has
471
+ /// changed even when the selection has not.
472
+ ///
473
+ /// @param themes the new set, or `None` to re-parse the document.
474
+ ///
475
+ /// # Returns
476
+ /// The active theme after the change.
477
+ pub async fn reset_themes(&self, themes: Option<Vec<String>>) -> ApiResult<Option<String>> {
478
+ let selected = self
479
+ .0
480
+ .theme_selected
481
+ .get()
482
+ .then(|| self.0.viewer_elem.get_attribute("theme"))
483
+ .flatten();
484
+
485
+ self.reset_available_themes(themes).await;
486
+ let available = self.get_available_themes().await?;
487
+ let kept = selected.filter(|name| available.contains(name));
488
+ self.0.theme_selected.set(kept.is_some());
489
+ let active = kept.or_else(|| available.first().cloned());
490
+ self.set_theme_attribute(active.as_deref())?;
491
+ Ok(active)
492
+ }
493
+
350
494
  /// Set the theme by name, or `None` for the default theme.
351
495
  ///
352
496
  /// A NAMED theme's host attribute write is SYNCHRONOUS ("stamp with
@@ -368,6 +512,7 @@ impl Presentation {
368
512
  /// # Returns
369
513
  /// A `bool` indicating whether the internal state changed.
370
514
  pub async fn set_theme_name(&self, theme: Option<&str>) -> ApiResult<bool> {
515
+ self.0.theme_selected.set(theme.is_some());
371
516
  if let Some(theme) = theme {
372
517
  if self.0.viewer_elem.get_attribute("theme").as_deref() == Some(theme) {
373
518
  return Ok(false);
@@ -377,20 +522,27 @@ impl Presentation {
377
522
  }
378
523
 
379
524
  let themes = self.get_available_themes().await?;
380
- let index = if let Some(theme) = theme {
381
- themes.iter().position(|x| x == theme)
382
- } else if !themes.is_empty() {
525
+ if theme.is_none() {
383
526
  self.set_theme_attribute(themes.first().map(|x| x.as_str()))?;
384
- Some(0)
385
- } else {
386
- self.set_theme_attribute(None)?;
387
- None
388
- };
527
+ }
389
528
 
390
- self.theme_config_updated.emit((themes, index));
391
529
  Ok(true)
392
530
  }
393
531
 
532
+ /// Publish the `theme_config_updated` snapshot — the available themes
533
+ /// and the host's current selection — to the component tree.
534
+ pub async fn publish_theme_config(&self) -> ApiResult<()> {
535
+ let themes = self.get_available_themes().await?;
536
+ let index = self
537
+ .0
538
+ .viewer_elem
539
+ .get_attribute("theme")
540
+ .and_then(|active| themes.iter().position(|x| *x == active));
541
+
542
+ self.theme_config_updated.emit((themes, index));
543
+ Ok(())
544
+ }
545
+
394
546
  /// Snapshot the drag state as a [`DragDropProps`] value for threading
395
547
  /// through the component tree without PubSub subscriptions.
396
548
  pub fn drag_drop_props(&self) -> DragDropProps {
@@ -427,6 +579,7 @@ impl Presentation {
427
579
 
428
580
  pub fn set_drag_image(&self, event: &DragEvent) -> ApiResult<()> {
429
581
  event.stop_propagation();
582
+ self.register_source_dragend(event)?;
430
583
  if let Some(dt) = event.data_transfer() {
431
584
  dt.set_drop_effect("move");
432
585
  }
@@ -485,8 +638,7 @@ impl Presentation {
485
638
  _ => None,
486
639
  };
487
640
 
488
- self.drag_target.borrow_mut().take();
489
- *self.drag_state.borrow_mut() = DragState::NoDrag;
641
+ self.end_drag();
490
642
  if let Some(action) = action {
491
643
  self.drop_received.emit(action);
492
644
  }
@@ -510,11 +662,15 @@ impl Presentation {
510
662
  /// End the drag/drop action by resetting the state to default.
511
663
  pub fn notify_drag_end(&self) {
512
664
  if self.drag_state.borrow().is_drag_in_progress() {
513
- self.drag_target.borrow_mut().take();
514
- *self.drag_state.borrow_mut() = DragState::NoDrag;
515
- if let Some(cb) = self.on_dragend.borrow().as_ref() {
516
- cb.emit(());
517
- }
665
+ self.end_drag();
666
+ }
667
+ }
668
+
669
+ fn end_drag(&self) {
670
+ self.drag_target.borrow_mut().take();
671
+ *self.drag_state.borrow_mut() = DragState::NoDrag;
672
+ if let Some(cb) = self.on_dragend.borrow().as_ref() {
673
+ cb.emit(());
518
674
  }
519
675
  }
520
676
 
@@ -522,6 +678,23 @@ impl Presentation {
522
678
  /// element so that drag-end cleanup fires even when Yew re-renders
523
679
  /// remove the original dragged element from the shadow DOM. The host
524
680
  /// element is outside the virtual DOM and therefore stable.
681
+ fn register_source_dragend(&self, event: &DragEvent) -> ApiResult<()> {
682
+ let target = event.target().into_apierror()?;
683
+ if let Some((prev_target, prev)) = self.source_dragend.borrow_mut().take() {
684
+ let _ = prev_target
685
+ .remove_event_listener_with_callback("dragend", prev.as_ref().unchecked_ref());
686
+ }
687
+
688
+ let this = self.clone();
689
+ let closure = Closure::wrap(Box::new(move |_event: DragEvent| {
690
+ this.notify_drag_end();
691
+ }) as Box<dyn FnMut(DragEvent)>);
692
+
693
+ target.add_event_listener_with_callback("dragend", closure.as_ref().unchecked_ref())?;
694
+ *self.source_dragend.borrow_mut() = Some((target, closure));
695
+ Ok(())
696
+ }
697
+
525
698
  fn register_host_dragend(&self) {
526
699
  if let Some(prev) = self.host_dragend.borrow_mut().take() {
527
700
  let _ = self
@@ -12,26 +12,29 @@
12
12
 
13
13
  use perspective_client::config::ViewConfig;
14
14
 
15
- use crate::presentation::{ColumnLocator, OpenColumnSettings};
15
+ use crate::presentation::{ColumnLocator, ColumnSettingsTarget, OpenColumnSettings};
16
16
  use crate::renderer::Renderer;
17
17
  use crate::session::SessionMetadata;
18
18
 
19
- /// Returns a [`ColumnLocator`] for a given column name, or [`None`] if no
20
- /// such column exists.
21
- pub fn get_column_locator(
19
+ /// Classify a column name against the session, config-first: the view
20
+ /// config is the commit of record for expressions and windows.
21
+ pub fn classify_column(
22
+ name: &str,
23
+ view_config: &ViewConfig,
22
24
  metadata: &SessionMetadata,
23
- name: Option<String>,
24
25
  ) -> Option<ColumnLocator> {
25
- name.and_then(|name| {
26
- if metadata.is_column_expression(&name) {
27
- Some(ColumnLocator::Expression(name))
28
- } else {
29
- metadata.get_table_columns().and_then(|x| {
30
- x.iter()
31
- .find_map(|n| (n == &name).then_some(ColumnLocator::Table(name.clone())))
32
- })
33
- }
34
- })
26
+ if view_config.windows.contains_key(name) || metadata.is_column_window(name) {
27
+ Some(ColumnLocator::Window(name.to_owned()))
28
+ } else if view_config.expressions.contains_key(name) || metadata.is_column_expression(name) {
29
+ Some(ColumnLocator::Expression(name.to_owned()))
30
+ } else if metadata
31
+ .get_table_columns()
32
+ .is_some_and(|cols| cols.iter().any(|col| col == name))
33
+ {
34
+ Some(ColumnLocator::Table(name.to_owned()))
35
+ } else {
36
+ None
37
+ }
35
38
  }
36
39
 
37
40
  /// Gets a [`ColumnLocator`] for the current UI's column settings state,
@@ -43,35 +46,62 @@ pub fn get_current_column_locator(
43
46
  open_column_settings: &OpenColumnSettings,
44
47
  renderer: &Renderer,
45
48
  view_config: &ViewConfig,
46
- _metadata: &SessionMetadata,
49
+ metadata: &SessionMetadata,
47
50
  ) -> Option<ColumnLocator> {
48
- open_column_settings
49
- .locator
50
- .clone()
51
- .filter(|locator| match locator {
52
- ColumnLocator::Table(_name) => {
53
- locator
54
- .name()
55
- .map(|name| {
56
- view_config.columns.iter().any(|maybe_col| {
57
- maybe_col
58
- .as_ref()
59
- .map(|col| col == name)
60
- .unwrap_or_default()
51
+ match open_column_settings.target.as_ref()? {
52
+ ColumnSettingsTarget::NewExpression => Some(ColumnLocator::NewExpression),
53
+ ColumnSettingsTarget::Column(name) => {
54
+ let locator = classify_column(name, view_config, metadata)?;
55
+ match locator {
56
+ ColumnLocator::Table(_) => {
57
+ let in_columns = view_config.columns.iter().any(|maybe_col| {
58
+ maybe_col
59
+ .as_ref()
60
+ .map(|col| col == name)
61
+ .unwrap_or_default()
62
+ });
63
+
64
+ (in_columns && renderer.can_render_column_styles()).then_some(locator)
65
+ },
66
+ locator => Some(locator),
67
+ }
68
+ },
69
+ }
70
+ }
71
+
72
+ #[cfg(test)]
73
+ mod tests {
74
+ use std::collections::HashMap;
75
+
76
+ use perspective_client::config::Expressions;
77
+
78
+ use super::*;
79
+
80
+ fn config_with_expression(name: &str, expr: &str) -> ViewConfig {
81
+ ViewConfig {
82
+ expressions: Expressions(HashMap::from([(name.to_owned(), expr.to_owned())])),
83
+ ..ViewConfig::default()
84
+ }
85
+ }
86
+
87
+ #[test]
88
+ fn classifies_from_the_config_before_metadata_catches_up() {
89
+ let config = config_with_expression("x", "1 + 1");
90
+ let metadata = SessionMetadata::default();
91
+ assert_eq!(
92
+ classify_column("x", &config, &metadata),
93
+ Some(ColumnLocator::Expression("x".to_owned()))
94
+ );
95
+ assert_eq!(classify_column("nope", &config, &metadata), None);
96
+ }
61
97
 
62
- // }) || view_config.group_by.iter().any(|col|
63
- // col == name) ||
64
- // view_config.split_by.iter().any(|col| col ==
65
- // name) ||
66
- // view_config.filter.iter().any(|col| col.column()
67
- // == name) ||
68
- // view_config.sort.iter().any(|col| &col.0 == name)
69
- // })
70
- })
71
- })
72
- .unwrap_or_default()
73
- && renderer.can_render_column_styles()
74
- },
75
- _ => true,
76
- })
98
+ #[test]
99
+ fn unnamed_expressions_are_keyed_by_their_text() {
100
+ let config = config_with_expression("\"Sales\" + 1", "\"Sales\" + 1");
101
+ let metadata = SessionMetadata::default();
102
+ assert_eq!(
103
+ classify_column("\"Sales\" + 1", &config, &metadata),
104
+ Some(ColumnLocator::Expression("\"Sales\" + 1".to_owned()))
105
+ );
106
+ }
77
107
  }
@@ -51,9 +51,6 @@ pub async fn get_viewer_config(
51
51
  None => presentation.get_selected_theme_name().await,
52
52
  };
53
53
  let title = session.get_title();
54
- // Placed ⇒ bound (or binding in flight): creation requires a `table`
55
- // by type, so a panel with neither a bound nor pending table is an
56
- // invariant violation, not a serializable state.
57
54
  let table = session
58
55
  .get_table()
59
56
  .map(|x| x.get_name().to_owned())
@@ -29,6 +29,7 @@ mod exports;
29
29
  mod fetch_column_stats;
30
30
  mod get_viewer_config;
31
31
  mod is_invalid_drop;
32
+ mod palette_set;
32
33
  mod plugin_column_styles;
33
34
  mod validate_expression;
34
35
 
@@ -40,5 +41,6 @@ pub use self::exports::*;
40
41
  pub use self::fetch_column_stats::*;
41
42
  pub use self::get_viewer_config::*;
42
43
  pub use self::is_invalid_drop::*;
44
+ pub use self::palette_set::*;
43
45
  pub use self::plugin_column_styles::*;
44
46
  pub use self::validate_expression::*;
@@ -0,0 +1,82 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ use std::collections::BTreeMap;
14
+
15
+ use crate::config::{CssKind, CssLiteralUse, NamedValue, assign_palette_names};
16
+ use crate::presentation::Presentation;
17
+ use crate::workspace::{PanelId, Workspace};
18
+
19
+ /// Every CSS literal in use across the workspace, as `(panel id, use)`,
20
+ /// in a deterministic order.
21
+ pub fn styles_in_use(workspace: &Workspace) -> Vec<(PanelId, CssLiteralUse)> {
22
+ let mut ids = workspace.panel_ids();
23
+ ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
24
+ let mut out = vec![];
25
+ for id in ids {
26
+ let Some(panel) = workspace.panel(&id) else {
27
+ continue;
28
+ };
29
+
30
+ let uses = {
31
+ let view_config = panel.session.get_view_config();
32
+ panel
33
+ .renderer
34
+ .css_literals_in_use(&view_config, &panel.session)
35
+ };
36
+
37
+ out.extend(uses.into_iter().map(|x| (id.clone(), x)));
38
+ }
39
+
40
+ out
41
+ }
42
+
43
+ /// The workspace palette set: the restored palette ∪ every literal in
44
+ /// use, named stably (see [`crate::config::assign_palette_names`]).
45
+ pub fn palette_set(workspace: &Workspace, presentation: &Presentation) -> BTreeMap<String, String> {
46
+ let host: Vec<NamedValue> = CssKind::ALL
47
+ .into_iter()
48
+ .flat_map(|kind| presentation.host_named_values(kind))
49
+ .collect();
50
+
51
+ let in_use: Vec<(CssKind, String)> = styles_in_use(workspace)
52
+ .into_iter()
53
+ .map(|(_, x)| (x.kind, x.literal))
54
+ .collect();
55
+
56
+ assign_palette_names(&presentation.palette(), &host, &in_use, &|name| {
57
+ presentation.resolve_css_var(name).is_some()
58
+ })
59
+ }
60
+
61
+ /// The named values of `kind` a picker offers: the palette set's
62
+ /// entries, then any theme/page-authored host entries not already in
63
+ /// the set by name.
64
+ pub fn named_values(
65
+ workspace: &Workspace,
66
+ presentation: &Presentation,
67
+ kind: CssKind,
68
+ ) -> Vec<NamedValue> {
69
+ let mut out: Vec<NamedValue> = palette_set(workspace, presentation)
70
+ .into_iter()
71
+ .filter(|(name, _)| name.starts_with(kind.var_prefix()))
72
+ .map(|(name, value)| NamedValue { name, value })
73
+ .collect();
74
+
75
+ for entry in presentation.host_named_values(kind) {
76
+ if !out.iter().any(|x| x.name == entry.name) {
77
+ out.push(entry);
78
+ }
79
+ }
80
+
81
+ out
82
+ }