@perspective-dev/viewer 5.5.0 → 5.6.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 (201) hide show
  1. package/README.md +82 -3
  2. package/dist/cdn/perspective-viewer.js +9 -9
  3. package/dist/cdn/perspective-viewer.js.map +3 -3
  4. package/dist/css/blueprint.css +1 -1
  5. package/dist/css/botanical.css +1 -1
  6. package/dist/css/dracula.css +1 -1
  7. package/dist/css/eggplant.css +1 -1
  8. package/dist/css/gruvbox-dark.css +1 -1
  9. package/dist/css/gruvbox.css +1 -1
  10. package/dist/css/icons.css +1 -1
  11. package/dist/css/intl/ar.css +1 -1
  12. package/dist/css/intl/de.css +1 -1
  13. package/dist/css/intl/es.css +1 -1
  14. package/dist/css/intl/fr.css +1 -1
  15. package/dist/css/intl/ja.css +1 -1
  16. package/dist/css/intl/pt.css +1 -1
  17. package/dist/css/intl/zh.css +1 -1
  18. package/dist/css/intl.css +1 -1
  19. package/dist/css/ledger.css +1 -1
  20. package/dist/css/monokai.css +1 -1
  21. package/dist/css/nord.css +1 -1
  22. package/dist/css/phosphor.css +1 -1
  23. package/dist/css/pro-dark.css +1 -1
  24. package/dist/css/pro.css +1 -1
  25. package/dist/css/solarized-dark.css +1 -1
  26. package/dist/css/solarized.css +1 -1
  27. package/dist/css/themes.css +1 -1
  28. package/dist/css/vaporwave.css +1 -1
  29. package/dist/css/velvet.css +1 -1
  30. package/dist/docs/perspective-docs.json +1 -1
  31. package/dist/esm/column-format.js +2 -0
  32. package/dist/esm/column-format.js.map +7 -0
  33. package/dist/esm/extensions.d.ts +3 -13
  34. package/dist/esm/perspective-viewer.d.ts +1 -1
  35. package/dist/esm/perspective-viewer.js +9 -9
  36. package/dist/esm/perspective-viewer.js.map +3 -3
  37. package/dist/esm/plugin.d.ts +4 -1
  38. package/dist/esm/select-detail.d.ts +15 -0
  39. package/dist/esm/select-detail.js +2 -0
  40. package/dist/esm/select-detail.js.map +7 -0
  41. package/dist/esm/ts-rs/DerivedTableConfig.d.ts +35 -0
  42. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -1
  43. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +7 -6
  44. package/dist/esm/ts-rs/RestoreOptions.d.ts +3 -7
  45. package/dist/esm/ts-rs/TableRef.d.ts +9 -0
  46. package/dist/esm/ts-rs/ViewerConfig.d.ts +7 -6
  47. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +7 -5
  48. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  49. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +9 -0
  50. package/dist/wasm/perspective-viewer.d.ts +126 -35
  51. package/dist/wasm/perspective-viewer.js +320 -185
  52. package/dist/wasm/perspective-viewer.wasm +0 -0
  53. package/dist/wasm/perspective-viewer.wasm.d.ts +21 -17
  54. package/package.json +33 -20
  55. package/src/css/column-selector.css +205 -50
  56. package/src/css/ui/containers/context-menu.css +12 -1
  57. package/src/css/ui/containers/dropdown-menu-items.css +29 -0
  58. package/src/css/ui/containers/scroll-panel.css +4 -1
  59. package/src/rust/agent/client/protocol.rs +0 -89
  60. package/src/rust/agent/markdown/mod.rs +0 -238
  61. package/src/rust/agent/preamble.md +1 -0
  62. package/src/rust/agent/tools.rs +2 -16
  63. package/src/rust/components/column_selector/active_column.rs +15 -5
  64. package/src/rust/components/column_selector/add_expression_button.rs +9 -26
  65. package/src/rust/components/column_selector/config_selector.rs +34 -6
  66. package/src/rust/components/column_selector/derived_control.rs +229 -0
  67. package/src/rust/components/column_selector/expr_edit_button.rs +3 -2
  68. package/src/rust/components/column_selector/filter_column.rs +0 -2
  69. package/src/rust/components/column_selector/inactive_column.rs +4 -5
  70. package/src/rust/components/column_selector/pivot_column.rs +21 -1
  71. package/src/rust/components/column_selector/sort_column.rs +4 -3
  72. package/src/rust/components/column_selector.rs +21 -1
  73. package/src/rust/components/column_settings_sidebar/save_settings.rs +5 -3
  74. package/src/rust/components/column_settings_sidebar/style_tab.rs +1 -0
  75. package/src/rust/components/column_settings_sidebar.rs +25 -19
  76. package/src/rust/components/debug_panel.rs +38 -29
  77. package/src/rust/components/dragdrop_list.rs +8 -9
  78. package/src/rust/components/export_dropdown.rs +6 -0
  79. package/src/rust/components/main_panel/msg.rs +6 -4
  80. package/src/rust/components/main_panel/reconcile.rs +6 -1
  81. package/src/rust/components/main_panel/render.rs +70 -51
  82. package/src/rust/components/main_panel/update.rs +39 -1
  83. package/src/rust/components/main_panel.rs +97 -59
  84. package/src/rust/components/new_panel_menu.rs +69 -1
  85. package/src/rust/components/panel_menu.rs +63 -12
  86. package/src/rust/components/plugin_tab.rs +3 -2
  87. package/src/rust/components/render_warning.rs +2 -1
  88. package/src/rust/components/settings_panel.rs +4 -4
  89. package/src/rust/components/status_bar.rs +80 -21
  90. package/src/rust/components/status_indicator.rs +8 -1
  91. package/src/rust/components/viewer/filters.rs +15 -3
  92. package/src/rust/components/viewer/msg.rs +8 -0
  93. package/src/rust/components/viewer/panels.rs +44 -4
  94. package/src/rust/components/viewer/render.rs +1 -0
  95. package/src/rust/components/viewer/settings.rs +0 -22
  96. package/src/rust/components/viewer/snapshots.rs +2 -2
  97. package/src/rust/components/viewer/wiring.rs +18 -1
  98. package/src/rust/components/viewer.rs +47 -27
  99. package/src/rust/components/window_editor.rs +5 -4
  100. package/src/rust/config/column_config_schema.rs +38 -377
  101. package/src/rust/config/export_method.rs +24 -0
  102. package/src/rust/config/number_string_format.rs +0 -94
  103. package/src/rust/config/options.rs +3 -7
  104. package/src/rust/config/plugin_static_config.rs +0 -42
  105. package/src/rust/config/viewer_config.rs +47 -17
  106. package/src/rust/config/workspace_config.rs +158 -128
  107. package/src/rust/custom_elements/viewer.rs +189 -129
  108. package/src/rust/custom_events.rs +33 -18
  109. package/src/rust/derived.rs +266 -0
  110. package/src/rust/exprtk/tokenize.rs +0 -54
  111. package/src/rust/js/plugin.rs +1 -1
  112. package/src/rust/lib.rs +3 -0
  113. package/src/rust/presentation.rs +23 -4
  114. package/src/rust/queries/column_locator.rs +55 -48
  115. package/src/rust/queries/get_viewer_config.rs +1 -1
  116. package/src/rust/queries/plugin_column_styles.rs +22 -23
  117. package/src/rust/renderer/dispatch.rs +2 -0
  118. package/src/rust/renderer/plugin_config.rs +652 -487
  119. package/src/rust/renderer/props.rs +4 -0
  120. package/src/rust/renderer/registry.rs +2 -6
  121. package/src/rust/renderer/state.rs +205 -0
  122. package/src/rust/renderer.rs +201 -59
  123. package/src/rust/session/metadata.rs +35 -19
  124. package/src/rust/session/op_queue.rs +430 -0
  125. package/src/rust/session/panel_state.rs +327 -0
  126. package/src/rust/session/view_subscription.rs +42 -3
  127. package/src/rust/session.rs +931 -419
  128. package/src/rust/tasks/apply_global_filters.rs +49 -35
  129. package/src/rust/tasks/auto_pause.rs +22 -12
  130. package/src/rust/tasks/copy_export.rs +72 -0
  131. package/src/rust/tasks/create_panel.rs +46 -21
  132. package/src/rust/tasks/derive.rs +312 -0
  133. package/src/rust/tasks/mod.rs +6 -2
  134. package/src/rust/tasks/pipeline.rs +152 -89
  135. package/src/rust/tasks/reset_all.rs +31 -20
  136. package/src/rust/tasks/restore_derived.rs +200 -0
  137. package/src/rust/tasks/restore_panel.rs +153 -141
  138. package/src/rust/tasks/send_column_config.rs +56 -29
  139. package/src/rust/tasks/send_plugin_config.rs +52 -23
  140. package/src/rust/tasks/set_edit_mode.rs +33 -25
  141. package/src/rust/tasks/sync_update_panels.rs +40 -13
  142. package/src/rust/tasks/table_lifecycle.rs +70 -34
  143. package/src/rust/tasks/transactional_restore.rs +387 -0
  144. package/src/rust/tasks/update_theme.rs +45 -6
  145. package/src/rust/ui/containers/scroll_panel.rs +17 -10
  146. package/src/rust/ui/containers/split_panel.rs +0 -41
  147. package/src/rust/ui/context_menu.rs +157 -18
  148. package/src/rust/ui/font_loader.rs +23 -35
  149. package/src/rust/ui/form/mirrored_textarea.rs +0 -12
  150. package/src/rust/ui/form/multi_stop_gradient_selector.rs +0 -89
  151. package/src/rust/ui/form/palette_selector.rs +0 -51
  152. package/src/rust/ui/portal.rs +4 -10
  153. package/src/rust/utils/browser/mod.rs +2 -3
  154. package/src/{ts/perspective-viewer.inline.ts → rust/utils/browser/mouse.rs} +11 -10
  155. package/src/rust/utils/browser/request_animation_frame.rs +27 -8
  156. package/src/rust/utils/css.rs +0 -360
  157. package/src/rust/utils/{tests/mod.rs → in_flight.rs} +62 -2
  158. package/src/rust/utils/local_fonts.rs +0 -36
  159. package/src/rust/utils/mod.rs +4 -3
  160. package/src/rust/utils/modal_position.rs +15 -21
  161. package/src/rust/utils/pending_effects.rs +6 -30
  162. package/src/rust/utils/{browser/tests/mod.rs → plugin_host_state.rs} +31 -1
  163. package/src/rust/utils/pubsub.rs +0 -1
  164. package/src/rust/workspace.rs +187 -140
  165. package/src/svg/derived.svg +6 -0
  166. package/src/themes/icons.css +1 -0
  167. package/src/themes/intl/ar.css +10 -0
  168. package/src/themes/intl/de.css +10 -0
  169. package/src/themes/intl/es.css +10 -0
  170. package/src/themes/intl/fr.css +10 -0
  171. package/src/themes/intl/ja.css +10 -0
  172. package/src/themes/intl/pt.css +10 -0
  173. package/src/themes/intl/zh.css +10 -0
  174. package/src/themes/intl.css +10 -0
  175. package/src/themes/monokai.css +1 -1
  176. package/src/themes/vaporwave.css +1 -1
  177. package/src/ts/extensions.ts +3 -36
  178. package/src/ts/perspective-viewer.ts +1 -1
  179. package/src/ts/plugin.ts +4 -1
  180. package/src/{rust/utils/tests/request_animation_frame.rs → ts/select-detail.ts} +32 -26
  181. package/src/ts/ts-rs/DerivedTableConfig.ts +22 -0
  182. package/src/ts/ts-rs/ExportMethod.ts +1 -1
  183. package/src/ts/ts-rs/PanelViewerConfig.ts +7 -6
  184. package/src/ts/ts-rs/RestoreOptions.ts +3 -7
  185. package/src/ts/ts-rs/TableRef.ts +9 -0
  186. package/src/ts/ts-rs/ViewerConfig.ts +7 -6
  187. package/src/ts/ts-rs/ViewerConfigUpdate.ts +7 -5
  188. package/src/ts/ts-rs/WorkspaceConfig.ts +7 -1
  189. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +7 -0
  190. package/dist/esm/perspective-viewer.inline.d.ts +0 -3
  191. package/dist/esm/perspective-viewer.inline.js +0 -21
  192. package/dist/esm/perspective-viewer.inline.js.map +0 -7
  193. package/src/rust/tasks/restore_and_render.rs +0 -107
  194. package/src/rust/utils/browser/tests/debounce.rs +0 -163
  195. package/src/rust/utils/tests/pubsub.rs +0 -95
  196. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline0.js +0 -0
  197. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline1.js +0 -0
  198. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline2.js +0 -0
  199. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline3.js +0 -0
  200. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline4.js +0 -0
  201. /package/dist/wasm/snippets/{perspective-viewer-ea71a3990cad35d7 → perspective-viewer-d0d525977a1acba2}/inline5.js +0 -0
@@ -10,10 +10,7 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- use std::cell::Cell;
14
- use std::rc::Rc;
15
-
16
- use super::pubsub::PubSub;
13
+ use super::in_flight::{InFlight, InFlightGuard};
17
14
 
18
15
  /// The element-scoped ledger of in-flight EFFECTS: every effectful public
19
16
  /// method (and the internal flows they schedule, e.g. the `before-resize`
@@ -22,41 +19,20 @@ use super::pubsub::PubSub;
22
19
  /// after the ledger drains, so "called before `flush`" implies "applied
23
20
  /// before `flush` resolves".
24
21
  #[derive(Clone, Default)]
25
- pub struct EffectLedger(Rc<EffectLedgerData>);
26
-
27
- #[derive(Default)]
28
- struct EffectLedgerData {
29
- count: Cell<u32>,
30
- on_drain: PubSub<()>,
31
- }
22
+ pub struct EffectLedger(InFlight);
32
23
 
33
- pub struct EffectGuard(EffectLedger);
24
+ pub type EffectGuard = InFlightGuard;
34
25
 
35
26
  impl EffectLedger {
36
27
  pub fn guard(&self) -> EffectGuard {
37
- self.0.count.set(self.0.count.get() + 1);
38
- EffectGuard(self.clone())
28
+ self.0.guard()
39
29
  }
40
30
 
41
31
  pub fn is_empty(&self) -> bool {
42
- self.0.count.get() == 0
32
+ self.0.is_empty()
43
33
  }
44
34
 
45
35
  pub async fn settle(&self) {
46
- while self.0.count.get() > 0 {
47
- if self.0.on_drain.read_next().await.is_err() {
48
- break;
49
- }
50
- }
51
- }
52
- }
53
-
54
- impl Drop for EffectGuard {
55
- fn drop(&mut self) {
56
- let data = &self.0.0;
57
- data.count.set(data.count.get() - 1);
58
- if data.count.get() == 0 {
59
- data.on_drain.emit(());
60
- }
36
+ self.0.settle().await
61
37
  }
62
38
  }
@@ -10,4 +10,34 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- mod debounce;
13
+ use web_sys::Element;
14
+
15
+ /// Mirror the host `<perspective-viewer>`'s `settings` attribute and
16
+ /// `shift-active` class onto one slotted plugin element so plugin stylesheets
17
+ /// can match them with `:host(...)`.
18
+ pub fn stamp_plugin_host_state(viewer: &Element, plugin: &Element) {
19
+ let _ = plugin.toggle_attribute_with_force("settings", viewer.has_attribute("settings"));
20
+ let _ = plugin
21
+ .class_list()
22
+ .toggle_with_force("shift-active", viewer.class_list().contains("shift-active"));
23
+ }
24
+
25
+ /// Apply [`stamp_plugin_host_state`] to every plugin element slotted into
26
+ /// `viewer`.
27
+ pub fn stamp_plugin_host_state_all(viewer: &Element) {
28
+ let children = viewer.children();
29
+ for i in 0..children.length() {
30
+ let Some(el) = children.item(i) else {
31
+ continue;
32
+ };
33
+
34
+ let is_plugin = el.tag_name().starts_with("PERSPECTIVE-VIEWER-")
35
+ && el
36
+ .get_attribute("slot")
37
+ .is_some_and(|slot| !slot.starts_with("tab-"));
38
+
39
+ if is_plugin {
40
+ stamp_plugin_host_state(viewer, &el);
41
+ }
42
+ }
43
+ }
@@ -127,7 +127,6 @@ impl<T: Clone + 'static> PubSub<T> {
127
127
  }
128
128
 
129
129
  /// Convert [`PubSub::emit`] to a `Box<dyn Fn(T)>`.
130
- #[must_use]
131
130
  pub fn as_boxfn(&self) -> Box<dyn Fn(T) + 'static> {
132
131
  let internal = PubSub(self.0.clone());
133
132
  Box::new(move |val: T| internal.emit(val))
@@ -13,13 +13,16 @@
13
13
  //! The multi-panel model backing a single `<perspective-viewer>`.
14
14
 
15
15
  use std::cell::{Cell, RefCell};
16
+ use std::collections::BTreeMap;
16
17
  use std::rc::Rc;
17
18
 
18
19
  use perspective_client::Client;
19
20
  use perspective_client::config::Filter;
20
21
 
22
+ use crate::config::{DerivedTableConfig, TableRef};
23
+ use crate::derived::{DerivedRegistry, DerivedTable};
21
24
  use crate::renderer::Renderer;
22
- use crate::session::Session;
25
+ use crate::session::{OverlayClause, Session};
23
26
  use crate::utils::{EffectLedger, PubSub, Subscription, spawn_owned};
24
27
 
25
28
  /// A unique identifier for a [`Panel`] within a [`Workspace`].
@@ -54,22 +57,37 @@ impl From<&str> for PanelId {
54
57
  #[derive(Default)]
55
58
  struct GlobalFilterSet {
56
59
  restored: Vec<Filter>,
57
- contributions: Vec<(PanelId, Vec<Filter>)>,
60
+
61
+ /// Each master's selection, every clause typed as the MASTER types its
62
+ /// column — what decides which listeners it applies to.
63
+ contributions: Vec<(PanelId, Vec<OverlayClause>)>,
58
64
  }
59
65
 
60
66
  impl GlobalFilterSet {
61
67
  /// The effective set: `restored`, then each contribution in order,
62
68
  /// deduped by clause equality.
63
69
  fn flatten(&self) -> Vec<Filter> {
64
- let mut flat: Vec<Filter> = Vec::new();
70
+ self.overlay().into_iter().map(|x| x.filter).collect()
71
+ }
72
+
73
+ /// [`Self::flatten`] with each clause's broadcaster type — untyped for the
74
+ /// restored bucket, which has no broadcaster.
75
+ fn overlay(&self) -> Vec<OverlayClause> {
76
+ let mut flat: Vec<OverlayClause> = Vec::new();
65
77
  let all = self
66
78
  .restored
67
79
  .iter()
68
- .chain(self.contributions.iter().flat_map(|(_, fs)| fs.iter()));
80
+ .cloned()
81
+ .map(OverlayClause::from)
82
+ .chain(
83
+ self.contributions
84
+ .iter()
85
+ .flat_map(|(_, fs)| fs.iter().cloned()),
86
+ );
69
87
 
70
- for filter in all {
71
- if !flat.contains(filter) {
72
- flat.push(filter.clone());
88
+ for clause in all {
89
+ if !flat.iter().any(|x| x.filter == clause.filter) {
90
+ flat.push(clause);
73
91
  }
74
92
  }
75
93
 
@@ -86,7 +104,8 @@ impl GlobalFilterSet {
86
104
  }
87
105
 
88
106
  /// Replace `id`'s contribution.
89
- fn set_contribution(&mut self, id: &PanelId, filters: Vec<Filter>) -> bool {
107
+ fn set_contribution(&mut self, id: &PanelId, filters: Vec<impl Into<OverlayClause>>) -> bool {
108
+ let filters = filters.into_iter().map(Into::into).collect::<Vec<_>>();
90
109
  self.with_change(|s| {
91
110
  if filters.is_empty() {
92
111
  s.contributions.retain(|(pid, _)| pid != id);
@@ -109,14 +128,14 @@ impl GlobalFilterSet {
109
128
  let owners = self
110
129
  .contributions
111
130
  .iter()
112
- .filter(|(_, fs)| fs.contains(&clause))
131
+ .filter(|(_, fs)| fs.iter().any(|f| f.filter == clause))
113
132
  .map(|(pid, _)| pid.clone())
114
133
  .collect();
115
134
 
116
135
  let changed = self.with_change(|s| {
117
136
  s.restored.retain(|f| f != &clause);
118
137
  for (_, fs) in s.contributions.iter_mut() {
119
- fs.retain(|f| f != &clause);
138
+ fs.retain(|f| f.filter != clause);
120
139
  }
121
140
 
122
141
  s.contributions.retain(|(_, fs)| !fs.is_empty());
@@ -308,9 +327,22 @@ struct WorkspaceData {
308
327
  filters: GlobalFilterSet,
309
328
  filters_changed: Rc<PubSub<()>>,
310
329
 
330
+ /// The set of hosted `Table`s changed: a client pushed a hosted-tables
331
+ /// update (observed post-sweep), or a derived `Table` was registered or
332
+ /// removed.
333
+ hosted_tables_changed: Rc<PubSub<()>>,
334
+
335
+ /// A panel's table binding changed (a rebind/pend committed, or a panel
336
+ /// entered or left the placed set), which is the input to per-table
337
+ /// consumer counts ([`Workspace::derived_consumers`]).
338
+ bindings_changed: Rc<PubSub<()>>,
339
+
311
340
  /// The loaded-clients registry + default designation.
312
341
  clients: ClientRegistry,
313
342
 
343
+ /// The live derived `Table`s this workspace owns.
344
+ derived: DerivedRegistry,
345
+
314
346
  /// Monotonic counter backing [`Workspace::generate_id`].
315
347
  next_id: usize,
316
348
 
@@ -356,7 +388,10 @@ impl Workspace {
356
388
  active: None,
357
389
  filters: GlobalFilterSet::default(),
358
390
  filters_changed: Rc::new(PubSub::default()),
391
+ hosted_tables_changed: Rc::new(PubSub::default()),
392
+ bindings_changed: Rc::new(PubSub::default()),
359
393
  clients: ClientRegistry::default(),
394
+ derived: DerivedRegistry::default(),
360
395
  next_id: 0,
361
396
  reservation: Reservation::Idle,
362
397
  pending_layout: None,
@@ -477,12 +512,26 @@ impl Workspace {
477
512
  self.0.borrow().filters.flatten()
478
513
  }
479
514
 
515
+ /// [`Self::global_filters`] as broadcast to the panels: each clause with
516
+ /// the type its column has in the master that selected it.
517
+ pub fn overlay(&self) -> Vec<OverlayClause> {
518
+ self.0.borrow().filters.overlay()
519
+ }
520
+
480
521
  /// A handle to the `filters_changed` PubSub (fires after any change to the
481
522
  /// global filter set).
482
523
  pub fn filters_changed(&self) -> Rc<PubSub<()>> {
483
524
  self.0.borrow().filters_changed.clone()
484
525
  }
485
526
 
527
+ pub fn hosted_tables_changed(&self) -> Rc<PubSub<()>> {
528
+ self.0.borrow().hosted_tables_changed.clone()
529
+ }
530
+
531
+ pub fn bindings_changed(&self) -> Rc<PubSub<()>> {
532
+ self.0.borrow().bindings_changed.clone()
533
+ }
534
+
486
535
  /// Run `f` against the filter set inside the borrow, then emit
487
536
  /// `filters_changed` — outside it — iff the effective set changed.
488
537
  /// Returns `f`'s auxiliary payload.
@@ -507,13 +556,13 @@ impl Workspace {
507
556
 
508
557
  /// Replace master `id`'s contribution with a new selection (empty =
509
558
  /// deselect). See [`GlobalFilterSet::set_contribution`].
510
- pub fn set_contribution(&self, id: &PanelId, filters: Vec<Filter>) {
559
+ pub fn set_contribution(&self, id: &PanelId, filters: Vec<OverlayClause>) {
511
560
  self.mutate_filters(|s| (s.set_contribution(id, filters), ()));
512
561
  }
513
562
 
514
563
  /// Drop master `id`'s contribution (deselect / demote / close).
515
564
  pub fn clear_contribution(&self, id: &PanelId) {
516
- self.mutate_filters(|s| (s.set_contribution(id, Vec::new()), ()));
565
+ self.mutate_filters(|s| (s.set_contribution(id, Vec::<OverlayClause>::new()), ()));
517
566
  }
518
567
 
519
568
  /// Remove the effective-set clause at `index`.
@@ -574,12 +623,19 @@ impl Workspace {
574
623
  }
575
624
  }
576
625
 
577
- /// Generate a fresh [`PanelId`], unique within this workspace.
626
+ /// Generate a fresh [`PanelId`], unique within this workspace — skipping
627
+ /// any taken by a placed panel (a restored config may carry saved
628
+ /// generated ids).
578
629
  pub fn generate_id(&self) -> PanelId {
579
630
  let mut data = self.0.borrow_mut();
580
- let n = data.next_id;
581
- data.next_id += 1;
582
- PanelId(format!("PERSPECTIVE_GENERATED_ID_{n}"))
631
+ loop {
632
+ let n = data.next_id;
633
+ data.next_id += 1;
634
+ let id = PanelId(format!("PERSPECTIVE_GENERATED_ID_{n}"));
635
+ if !data.panels.iter().any(|p| p.panel.id == id) {
636
+ return id;
637
+ }
638
+ }
583
639
  }
584
640
 
585
641
  /// The id of the active panel, or `None` with zero panels.
@@ -702,6 +758,8 @@ impl Workspace {
702
758
  if let Some(pubsub) = staged_pubsub {
703
759
  pubsub.emit(());
704
760
  }
761
+
762
+ self.bindings_changed().emit(());
705
763
  }
706
764
 
707
765
  /// Hold `panel` in the reservation slot ([`Reservation::Pending`]):
@@ -805,7 +863,9 @@ impl Workspace {
805
863
  pub fn remove_panel(&self, id: &PanelId) -> Option<Panel> {
806
864
  let (removed, changed, filters_pubsub, was_staged, staged_pubsub) = {
807
865
  let mut data = self.0.borrow_mut();
808
- let changed = data.filters.set_contribution(id, Vec::new());
866
+ let changed = data
867
+ .filters
868
+ .set_contribution(id, Vec::<OverlayClause>::new());
809
869
  let (removed, was_staged) = match data.panels.iter().position(|p| &p.panel.id == id) {
810
870
  Some(idx) => {
811
871
  let entry = data.panels.remove(idx);
@@ -838,6 +898,10 @@ impl Workspace {
838
898
  staged_pubsub.emit(());
839
899
  }
840
900
 
901
+ if removed.is_some() {
902
+ self.bindings_changed().emit(());
903
+ }
904
+
841
905
  removed
842
906
  }
843
907
 
@@ -1013,136 +1077,119 @@ impl Workspace {
1013
1077
  None => current.is_none().then(|| self.default_client()).flatten(),
1014
1078
  }
1015
1079
  }
1016
- }
1017
1080
 
1018
- #[cfg(test)]
1019
- mod tests {
1020
- use perspective_client::config::FilterTerm;
1021
-
1022
- use super::*;
1023
-
1024
- fn f(col: &str, term: &str) -> Filter {
1025
- Filter::new(col, "==", FilterTerm::Scalar(term.into()))
1026
- }
1027
-
1028
- fn p(id: &str) -> PanelId {
1029
- PanelId::from(id)
1030
- }
1031
-
1032
- #[test]
1033
- fn flatten_orders_and_dedups_across_buckets() {
1034
- let mut s = GlobalFilterSet::default();
1035
- assert!(s.set_restored(vec![f("a", "1"), f("b", "2")]));
1036
- // A non-empty contribution drops the restored bucket entirely.
1037
- assert!(s.set_contribution(&p("x"), vec![f("b", "2"), f("c", "3")]));
1038
- assert_eq!(s.flatten(), vec![f("b", "2"), f("c", "3")]);
1039
- assert!(s.set_contribution(&p("y"), vec![f("c", "3"), f("d", "4")]));
1040
- assert_eq!(s.flatten(), vec![f("b", "2"), f("c", "3"), f("d", "4")]);
1041
- }
1042
-
1043
- #[test]
1044
- fn set_contribution_replaces_not_accumulates() {
1045
- let mut s = GlobalFilterSet::default();
1046
- assert!(s.set_contribution(&p("x"), vec![f("a", "1")]));
1047
- assert!(s.set_contribution(&p("x"), vec![f("a", "2")]));
1048
- assert_eq!(s.flatten(), vec![f("a", "2")]);
1049
- // Re-selecting the same value reports no visible change.
1050
- assert!(!s.set_contribution(&p("x"), vec![f("a", "2")]));
1051
- // Empty = deselect: the entry is removed.
1052
- assert!(s.set_contribution(&p("x"), Vec::new()));
1053
- assert_eq!(s.flatten(), Vec::<Filter>::new());
1054
- }
1055
-
1056
- #[test]
1057
- fn contributions_are_per_panel() {
1058
- let mut s = GlobalFilterSet::default();
1059
- s.set_contribution(&p("x"), vec![f("a", "1")]);
1060
- s.set_contribution(&p("y"), vec![f("b", "2")]);
1061
- // Clearing one master's contribution leaves the other's intact.
1062
- assert!(s.set_contribution(&p("x"), Vec::new()));
1063
- assert_eq!(s.flatten(), vec![f("b", "2")]);
1064
- }
1065
-
1066
- #[test]
1067
- fn remove_clause_removes_from_every_bucket_and_reports_owners() {
1068
- let mut s = GlobalFilterSet::default();
1069
- s.set_contribution(&p("x"), vec![f("a", "1"), f("b", "2")]);
1070
- s.set_contribution(&p("y"), vec![f("a", "1")]);
1071
- // flatten = [a==1, b==2]; removing index 0 must drop BOTH copies of
1072
- // a==1 (owners x AND y), and the now-empty "y" entry with it.
1073
- let (changed, owners) = s.remove_clause(0);
1074
- assert!(changed);
1075
- assert_eq!(owners, vec![p("x"), p("y")]);
1076
- assert_eq!(s.flatten(), vec![f("b", "2")]);
1077
- // Out-of-range is a no-op.
1078
- assert_eq!(s.remove_clause(5), (false, Vec::new()));
1079
- }
1080
-
1081
- #[test]
1082
- fn restored_bucket_survives_deselect_but_not_selection() {
1083
- let mut s = GlobalFilterSet::default();
1084
- s.set_restored(vec![f("a", "1")]);
1085
- // A deselect (empty contribution) does NOT drop the restored bucket.
1086
- assert!(!s.set_contribution(&p("x"), Vec::new()));
1087
- assert_eq!(s.flatten(), vec![f("a", "1")]);
1088
- // A real selection replaces it.
1089
- assert!(s.set_contribution(&p("x"), vec![f("b", "2")]));
1090
- assert_eq!(s.flatten(), vec![f("b", "2")]);
1091
- assert!(s.restored.is_empty());
1092
- }
1093
-
1094
- #[test]
1095
- fn clear_drops_everything_and_reports_owners() {
1096
- let mut s = GlobalFilterSet::default();
1097
- s.set_restored(vec![f("a", "1")]);
1098
- s.set_contribution(&p("x"), vec![f("b", "2")]);
1099
- let (changed, owners) = s.clear();
1100
- assert!(changed);
1101
- assert_eq!(owners, vec![p("x")]);
1102
- assert_eq!(s.flatten(), Vec::<Filter>::new());
1103
- assert_eq!(s.clear(), (false, Vec::new()));
1104
- }
1105
-
1106
- #[test]
1107
- fn flush_state_spawns_exactly_once_per_cycle() {
1108
- let (state, spawn) = FlushState::Idle.mark(true, false);
1109
- assert!(spawn);
1110
- assert_eq!(state, FlushState::Queued {
1111
- layout: true,
1112
- active: false
1113
- });
1081
+ /// Every derivation's persisted definition, for `saveWorkspace`.
1082
+ pub fn derived_configs(&self) -> BTreeMap<String, DerivedTableConfig> {
1083
+ self.0.borrow().derived.configs()
1084
+ }
1114
1085
 
1115
- // Subsequent marks merge without re-spawning.
1116
- let (state, spawn) = state.mark(false, true);
1117
- assert!(!spawn);
1118
- assert_eq!(state, FlushState::Queued {
1119
- layout: true,
1120
- active: true
1121
- });
1086
+ pub fn is_derived(&self, name: &str) -> bool {
1087
+ self.0.borrow().derived.contains(name)
1088
+ }
1122
1089
 
1123
- let (state, spawn) = state.mark(true, true);
1124
- assert!(!spawn);
1125
- assert_eq!(state, FlushState::Queued {
1126
- layout: true,
1127
- active: true
1128
- });
1090
+ pub fn derived_names(&self) -> Vec<String> {
1091
+ self.0.borrow().derived.names()
1129
1092
  }
1130
1093
 
1131
- #[test]
1132
- fn flush_state_ignores_empty_marks() {
1133
- // A no-event mark neither queues nor spawns — `Queued{false,false}`
1134
- // is unconstructible through `mark`.
1135
- let (state, spawn) = FlushState::Idle.mark(false, false);
1136
- assert!(!spawn);
1137
- assert_eq!(state, FlushState::Idle);
1094
+ /// The recorded definition of a derived `Table`.
1095
+ pub fn derived_config(&self, name: &str) -> Option<DerivedTableConfig> {
1096
+ self.0.borrow().derived.get(name).map(|x| x.config())
1097
+ }
1098
+
1099
+ /// The name of a live derivation structurally equal to `config`.
1100
+ pub fn derived_find_structural(&self, config: &DerivedTableConfig) -> Option<String> {
1101
+ self.0.borrow().derived.find_structural(config)
1102
+ }
1138
1103
 
1139
- let queued = FlushState::Queued {
1140
- layout: true,
1141
- active: false,
1104
+ /// `name`'s definition as a self-contained [`TableRef`], recursively
1105
+ /// inlining each derived ancestor down to a concrete table name.
1106
+ pub fn inline_table_ref(&self, name: &str) -> TableRef {
1107
+ let ancestors = self.derived_ancestors(name);
1108
+ let Some(base) = ancestors.last() else {
1109
+ return TableRef::Name(name.to_owned());
1142
1110
  };
1143
1111
 
1144
- let (state, spawn) = queued.mark(false, false);
1145
- assert!(!spawn);
1146
- assert_eq!(state, queued);
1112
+ let mut out = TableRef::Name(base.clone());
1113
+ let chain = std::iter::once(name)
1114
+ .chain(ancestors[..ancestors.len() - 1].iter().map(String::as_str))
1115
+ .collect::<Vec<_>>();
1116
+
1117
+ for level in chain.into_iter().rev() {
1118
+ match self.derived_config(level) {
1119
+ Some(config) => {
1120
+ out = TableRef::Derived(Box::new(DerivedTableConfig {
1121
+ table: out,
1122
+ view_config: config.view_config,
1123
+ }));
1124
+ },
1125
+ None => return TableRef::Name(name.to_owned()),
1126
+ }
1127
+ }
1128
+
1129
+ out
1130
+ }
1131
+
1132
+ /// The `Table` names `name` derives from, nearest first.
1133
+ pub fn derived_ancestors(&self, name: &str) -> Vec<String> {
1134
+ self.0.borrow().derived.ancestors(name)
1135
+ }
1136
+
1137
+ /// How many panels and derivations read `name`.
1138
+ pub fn derived_consumers(&self, name: &str) -> usize {
1139
+ let panels = self.panels();
1140
+ let data = self.0.borrow();
1141
+ let bound = panels
1142
+ .iter()
1143
+ .filter(|panel| {
1144
+ panel
1145
+ .session
1146
+ .get_table()
1147
+ .map(|table| table.get_name().to_owned())
1148
+ .or_else(|| panel.session.pending_table())
1149
+ .is_some_and(|bound| bound == name)
1150
+ })
1151
+ .count();
1152
+
1153
+ bound + data.derived.children(name).len()
1154
+ }
1155
+
1156
+ pub fn register_derived(&self, name: String, table: DerivedTable) {
1157
+ self.0.borrow_mut().derived.insert(name, table);
1158
+ self.hosted_tables_changed().emit(());
1159
+ }
1160
+
1161
+ /// Take a derivation out of the registry so the caller can await its
1162
+ /// deletion outside the borrow.
1163
+ pub fn take_derived(&self, name: &str) -> Option<DerivedTable> {
1164
+ let derived = self.0.borrow_mut().derived.remove(name);
1165
+ if derived.is_some() {
1166
+ self.hosted_tables_changed().emit(());
1167
+ }
1168
+
1169
+ derived
1170
+ }
1171
+
1172
+ /// Which of `order` may be reused rather than rebuilt.
1173
+ pub fn derived_reusable(
1174
+ &self,
1175
+ order: &[String],
1176
+ configs: &BTreeMap<String, DerivedTableConfig>,
1177
+ ) -> std::collections::BTreeSet<String> {
1178
+ crate::derived::reusable(order, configs, &self.0.borrow().derived)
1179
+ }
1180
+
1181
+ /// The definitions waiting on a parent that is not yet hosted.
1182
+ pub fn derived_pending(&self) -> BTreeMap<String, DerivedTableConfig> {
1183
+ self.0.borrow().derived.pending()
1184
+ }
1185
+
1186
+ /// Whether `name` is a derived definition awaiting its parent, which a
1187
+ /// panel bind must PEND on rather than reject as unknown.
1188
+ pub fn is_derived_pending(&self, name: &str) -> bool {
1189
+ self.0.borrow().derived.pending().contains_key(name)
1190
+ }
1191
+
1192
+ pub fn set_derived_pending(&self, pending: BTreeMap<String, DerivedTableConfig>) {
1193
+ self.0.borrow_mut().derived.set_pending(pending);
1147
1194
  }
1148
1195
  }
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="#042121"
2
+ stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
3
+ <circle cx="3.5" cy="3.5" r="1.75"/>
4
+ <circle cx="9.5" cy="9.5" r="1.75"/>
5
+ <path d="M3.5 5.25v2.25a2 2 0 0 0 2 2h2.25"/>
6
+ </svg>
@@ -63,6 +63,7 @@ perspective-viewer-tab {
63
63
  --psp-icon--reset--mask-image: url("../svg/revert-icon.svg");
64
64
  --psp-icon--copy--mask-image: url("../svg/duplicate-icon.svg");
65
65
  --psp-icon--new-panel--mask-image: url("../svg/new-panel-icon.svg");
66
+ --psp-derived--mask-image: url("../svg/derived.svg");
66
67
  --psp-icon--theme--mask-image: url("../svg/theme-icon.svg");
67
68
 
68
69
  --psp-icon--drawer-tab--mask-image: url(../svg/drawer-tab.svg);
@@ -69,6 +69,8 @@ perspective-viewer {
69
69
  --psp-label--scroll-lock-alt-toggle--content: "تمرير محاذى";
70
70
 
71
71
  --psp-label--color--content: "اللون";
72
+ --psp-label--palette--content: "لوحة الألوان";
73
+ --psp-label--gradient--content: "تدرج";
72
74
  --psp-label--timezone--content: "المنطقة الزمنية";
73
75
  --psp-label--date-style--content: "نمط التاريخ";
74
76
  --psp-label--time-style--content: "نمط الوقت";
@@ -195,4 +197,12 @@ perspective-viewer {
195
197
  --psp-label--legend-opacity--content: "العتامة";
196
198
  --psp-label--tooltip-max-column-px--content: "أقصى عرض للعمود";
197
199
  --psp-label--tooltip-opacity--content: "العتامة";
200
+
201
+ /* Derived tables */
202
+ --psp-label--derive-table--content: "اشتقاق جدول";
203
+ --psp-label--derive-table-title--content: "اشتقاق جدول جديد من هذا العرض";
204
+ --psp-label--derived-snapshot--content: "لقطة";
205
+ --psp-label--derived-snapshot-title--content: "مصدر البيانات هذا لا يبث التحديثات - بيانات هذا الجدول ثابتة عند لحظة اشتقاقه.";
206
+ --psp-label--derived-release-title--content: "العودة إلى الجدول الأصل";
207
+ --psp-label--derived-release-delete-title--content: "العودة إلى الجدول الأصل - انقر مع الضغط على Shift لحذفه";
198
208
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "Farbe";
77
+ --psp-label--palette--content: "Palette";
78
+ --psp-label--gradient--content: "Gradient";
77
79
  --psp-label--timezone--content: "Zeitzone";
78
80
  --psp-label--date-style--content: "Datumsstil";
79
81
  --psp-label--time-style--content: "Zeitstil";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "Spalte";
205
207
  --psp-label--group-format--content: "Format";
206
208
  --psp-label--column-size-override--content: "Spaltenbreite";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "Tabelle ableiten";
212
+ --psp-label--derive-table-title--content: "Eine neue Tabelle aus dieser Ansicht ableiten";
213
+ --psp-label--derived-snapshot--content: "Snapshot";
214
+ --psp-label--derived-snapshot-title--content: "Diese Datenquelle streamt nicht - die Daten dieser Tabelle sind auf den Stand ihrer Ableitung eingefroren.";
215
+ --psp-label--derived-release-title--content: "Zurück zur übergeordneten Tabelle";
216
+ --psp-label--derived-release-delete-title--content: "Zurück zur übergeordneten Tabelle - Umschalt-Klick löscht sie";
207
217
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "Color";
77
+ --psp-label--palette--content: "Paleta";
78
+ --psp-label--gradient--content: "Gradiente";
77
79
  --psp-label--timezone--content: "Zona horaria";
78
80
  --psp-label--date-style--content: "Estilo de fecha";
79
81
  --psp-label--time-style--content: "Estilo de hora";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "Columna";
205
207
  --psp-label--group-format--content: "Formato";
206
208
  --psp-label--column-size-override--content: "Ancho de columna";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "Derivar tabla";
212
+ --psp-label--derive-table-title--content: "Derivar una nueva tabla a partir de esta vista";
213
+ --psp-label--derived-snapshot--content: "instantánea";
214
+ --psp-label--derived-snapshot-title--content: "Esta fuente de datos no transmite en continuo - los datos de esta tabla quedan fijados en el momento de su derivación.";
215
+ --psp-label--derived-release-title--content: "Volver a la tabla padre";
216
+ --psp-label--derived-release-delete-title--content: "Volver a la tabla padre - mayús+clic para eliminarla";
207
217
  }
@@ -74,6 +74,8 @@ perspective-viewer {
74
74
 
75
75
  /* Column Settings */
76
76
  --psp-label--color--content: "Couleur";
77
+ --psp-label--palette--content: "Palette";
78
+ --psp-label--gradient--content: "Dégradé";
77
79
  --psp-label--timezone--content: "Fuseau horaire";
78
80
  --psp-label--date-style--content: "Style de date";
79
81
  --psp-label--time-style--content: "Style d'heure";
@@ -204,4 +206,12 @@ perspective-viewer {
204
206
  --psp-label--group-column--content: "Colonne";
205
207
  --psp-label--group-format--content: "Format";
206
208
  --psp-label--column-size-override--content: "Largeur de colonne";
209
+
210
+ /* Derived tables */
211
+ --psp-label--derive-table--content: "Dériver une table";
212
+ --psp-label--derive-table-title--content: "Dériver une nouvelle table à partir de cette vue";
213
+ --psp-label--derived-snapshot--content: "instantané";
214
+ --psp-label--derived-snapshot-title--content: "Cette source de données ne diffuse pas en continu - les données de cette table sont figées au moment de sa dérivation.";
215
+ --psp-label--derived-release-title--content: "Revenir à la table parente";
216
+ --psp-label--derived-release-delete-title--content: "Revenir à la table parente - maj-clic pour la supprimer";
207
217
  }