@perspective-dev/viewer 4.5.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/dist/cdn/perspective-viewer.js +20 -1
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/extensions.d.ts +22 -3
  24. package/dist/esm/perspective-viewer.d.ts +9 -0
  25. package/dist/esm/perspective-viewer.inline.js +20 -1
  26. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  27. package/dist/esm/perspective-viewer.js +20 -1
  28. package/dist/esm/perspective-viewer.js.map +4 -4
  29. package/dist/esm/plugin.d.ts +71 -5
  30. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  31. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  32. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  33. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  34. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  35. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  36. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  37. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  38. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +41 -0
  39. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  40. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  41. package/dist/esm/ts-rs/ViewerConfig.d.ts +8 -2
  42. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +9 -0
  43. package/dist/esm/ts-rs/WindowAggregate.d.ts +1 -0
  44. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  45. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  46. package/dist/esm/ts-rs/WindowSpec.d.ts +46 -0
  47. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  48. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  49. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  50. package/dist/wasm/perspective-viewer.d.ts +299 -78
  51. package/dist/wasm/perspective-viewer.js +544 -186
  52. package/dist/wasm/perspective-viewer.wasm +0 -0
  53. package/dist/wasm/perspective-viewer.wasm.d.ts +34 -26
  54. package/dist/wasm/snippets/perspective-viewer-8a8c7d5d696b43ae/inline0.js +6 -0
  55. package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline3.js → perspective-viewer-8a8c7d5d696b43ae/inline4.js} +5 -0
  56. package/package.json +3 -2
  57. package/src/css/_context_menu.css +14 -0
  58. package/src/css/column-selector.css +1 -0
  59. package/src/css/column-settings-panel.css +68 -3
  60. package/src/css/column-style.css +4 -3
  61. package/src/css/config-selector.css +22 -1
  62. package/src/css/containers/context-menu.css +107 -0
  63. package/src/css/containers/tabs.css +2 -2
  64. package/src/css/panel-tab.css +193 -0
  65. package/src/css/plugin-selector.css +7 -10
  66. package/src/css/status-bar.css +75 -81
  67. package/src/css/viewer.css +188 -70
  68. package/src/rust/components/column_dropdown.rs +40 -30
  69. package/src/rust/components/column_selector/active_column.rs +28 -45
  70. package/src/rust/components/column_selector/aggregate_selector.rs +4 -7
  71. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  72. package/src/rust/components/column_selector/config_selector.rs +40 -82
  73. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  74. package/src/rust/components/column_selector/filter_column.rs +5 -15
  75. package/src/rust/components/column_selector/inactive_column.rs +11 -9
  76. package/src/rust/components/column_selector/sort_column.rs +3 -6
  77. package/src/rust/components/column_selector.rs +25 -14
  78. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  79. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  80. package/src/rust/components/column_settings_sidebar.rs +163 -16
  81. package/src/rust/components/containers/dragdrop_list.rs +73 -3
  82. package/src/rust/components/containers/sidebar.rs +99 -22
  83. package/src/rust/components/containers/split_panel.rs +181 -62
  84. package/src/rust/components/context_menu.rs +130 -0
  85. package/src/rust/components/editable_header.rs +2 -0
  86. package/src/rust/components/form/debug.rs +3 -0
  87. package/src/rust/components/global_filter_bar.rs +76 -0
  88. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  89. package/src/rust/components/main_panel/msg.rs +62 -0
  90. package/src/rust/components/main_panel/presize.rs +220 -0
  91. package/src/rust/components/main_panel/reconcile.rs +224 -0
  92. package/src/rust/components/main_panel/render.rs +385 -0
  93. package/src/rust/components/main_panel/update.rs +184 -0
  94. package/src/rust/components/main_panel.rs +316 -71
  95. package/src/rust/components/mod.rs +5 -0
  96. package/src/rust/components/panel_menu.rs +493 -0
  97. package/src/rust/components/panel_tab.rs +510 -0
  98. package/src/rust/components/plugin_tab.rs +1 -1
  99. package/src/rust/components/portal.rs +7 -0
  100. package/src/rust/components/settings_panel.rs +13 -5
  101. package/src/rust/components/status_bar.rs +110 -178
  102. package/src/rust/components/status_indicator.rs +6 -9
  103. package/src/rust/components/style/mod.rs +5 -0
  104. package/src/rust/components/style/style_provider.rs +2 -0
  105. package/src/rust/components/viewer/filters.rs +133 -0
  106. package/src/rust/components/viewer/msg.rs +182 -0
  107. package/src/rust/components/viewer/panels.rs +326 -0
  108. package/src/rust/components/viewer/render.rs +255 -0
  109. package/src/rust/components/viewer/settings.rs +434 -0
  110. package/src/rust/components/viewer/snapshots.rs +134 -0
  111. package/src/rust/components/viewer/wiring.rs +505 -0
  112. package/src/rust/components/viewer.rs +245 -756
  113. package/src/rust/components/window_editor.rs +1013 -0
  114. package/src/rust/config/export_method.rs +1 -1
  115. package/src/rust/config/mod.rs +4 -0
  116. package/src/rust/config/options.rs +71 -0
  117. package/src/rust/config/viewer_config.rs +24 -2
  118. package/src/rust/config/workspace_config.rs +92 -0
  119. package/src/rust/custom_elements/copy_dropdown.rs +3 -1
  120. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  121. package/src/rust/custom_elements/export_dropdown.rs +3 -1
  122. package/src/rust/custom_elements/viewer.rs +1015 -286
  123. package/src/rust/custom_events.rs +212 -61
  124. package/src/rust/js/mod.rs +2 -0
  125. package/src/rust/js/plugin.rs +297 -10
  126. package/src/rust/js/regular_layout.rs +258 -0
  127. package/src/rust/lib.rs +24 -2
  128. package/src/rust/presentation/column_locator.rs +16 -1
  129. package/src/rust/presentation.rs +66 -33
  130. package/src/rust/queries/columns_iter_set.rs +7 -1
  131. package/src/rust/queries/get_viewer_config.rs +29 -11
  132. package/src/rust/queries/plugin_column_styles.rs +2 -2
  133. package/src/rust/renderer/activate.rs +41 -14
  134. package/src/rust/renderer/dispatch.rs +463 -0
  135. package/src/rust/renderer/plugin_config.rs +495 -0
  136. package/src/rust/renderer/registry.rs +18 -6
  137. package/src/rust/renderer/render_timer.rs +29 -10
  138. package/src/rust/renderer.rs +384 -838
  139. package/src/rust/session/column_defaults_update.rs +26 -8
  140. package/src/rust/session/drag_drop_update.rs +18 -0
  141. package/src/rust/session/metadata.rs +66 -9
  142. package/src/rust/session/replace_expression_update.rs +1 -0
  143. package/src/rust/session/view_subscription.rs +30 -15
  144. package/src/rust/session.rs +755 -264
  145. package/src/rust/tasks/apply_global_filters.rs +89 -0
  146. package/src/rust/tasks/auto_pause.rs +171 -0
  147. package/src/rust/tasks/copy_export.rs +29 -19
  148. package/src/rust/tasks/create_panel.rs +243 -0
  149. package/src/rust/tasks/dismiss_render_warning.rs +3 -1
  150. package/src/rust/tasks/edit_expression.rs +4 -4
  151. package/src/rust/tasks/edit_window.rs +155 -0
  152. package/src/rust/tasks/eject.rs +64 -23
  153. package/src/rust/tasks/mod.rs +30 -4
  154. package/src/rust/tasks/pipeline.rs +411 -0
  155. package/src/rust/tasks/presize_panels.rs +324 -0
  156. package/src/rust/tasks/reset_all.rs +22 -12
  157. package/src/rust/tasks/resize_observer.rs +124 -87
  158. package/src/rust/tasks/restore_and_render.rs +80 -86
  159. package/src/rust/tasks/restore_panel.rs +145 -0
  160. package/src/rust/tasks/send_column_config.rs +5 -2
  161. package/src/rust/tasks/send_plugin_config.rs +5 -2
  162. package/src/rust/tasks/set_edit_mode.rs +59 -0
  163. package/src/rust/tasks/sync_update_panels.rs +152 -0
  164. package/src/rust/tasks/table_lifecycle.rs +158 -0
  165. package/src/rust/tasks/update_theme.rs +56 -12
  166. package/src/rust/utils/browser/dragdrop.rs +12 -0
  167. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  168. package/src/rust/utils/completion.rs +56 -0
  169. package/src/rust/utils/custom_element.rs +5 -0
  170. package/src/rust/utils/debounce.rs +189 -17
  171. package/src/rust/utils/mod.rs +6 -0
  172. package/src/rust/utils/pending_effects.rs +62 -0
  173. package/src/rust/utils/spawn.rs +33 -0
  174. package/src/rust/utils/weak_scope.rs +0 -10
  175. package/src/rust/workspace.rs +846 -0
  176. package/src/svg/active-indicator.svg +3 -0
  177. package/src/svg/pin-icon.svg +5 -0
  178. package/src/themes/botanical.css +5 -2
  179. package/src/themes/defaults.css +54 -2
  180. package/src/themes/dracula.css +6 -3
  181. package/src/themes/gruvbox-dark.css +5 -2
  182. package/src/themes/gruvbox.css +5 -2
  183. package/src/themes/icons.css +5 -2
  184. package/src/themes/intl/de.css +7 -0
  185. package/src/themes/intl/es.css +7 -0
  186. package/src/themes/intl/fr.css +7 -0
  187. package/src/themes/intl/ja.css +7 -0
  188. package/src/themes/intl/pt.css +7 -0
  189. package/src/themes/intl/zh.css +7 -0
  190. package/src/themes/intl.css +10 -1
  191. package/src/themes/monokai.css +5 -2
  192. package/src/themes/phosphor.css +5 -2
  193. package/src/themes/pro-dark.css +5 -2
  194. package/src/themes/pro.css +2 -1
  195. package/src/themes/solarized-dark.css +5 -2
  196. package/src/themes/solarized.css +5 -2
  197. package/src/themes/vaporwave.css +5 -2
  198. package/src/ts/bootstrap.ts +8 -7
  199. package/src/ts/extensions.ts +30 -2
  200. package/src/ts/perspective-viewer.ts +10 -0
  201. package/src/ts/plugin.ts +72 -5
  202. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  203. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  204. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  205. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  206. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  207. package/src/ts/ts-rs/Layout.ts +9 -0
  208. package/src/ts/ts-rs/Orientation.ts +6 -0
  209. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  210. package/src/ts/ts-rs/PanelViewerConfig.ts +16 -0
  211. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  212. package/src/ts/ts-rs/TabLayout.ts +11 -0
  213. package/src/ts/ts-rs/ViewerConfig.ts +7 -2
  214. package/src/ts/ts-rs/ViewerConfigUpdate.ts +9 -0
  215. package/src/ts/ts-rs/WindowAggregate.ts +3 -0
  216. package/src/ts/ts-rs/WindowSort.ts +9 -0
  217. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  218. package/src/ts/ts-rs/WindowSpec.ts +46 -0
  219. package/src/ts/ts-rs/Windows.ts +10 -0
  220. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  221. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  222. package/src/rust/tasks/intersection_observer.rs +0 -98
  223. package/src/rust/tasks/update_and_render.rs +0 -110
  224. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline0.js → perspective-viewer-8a8c7d5d696b43ae/inline1.js} +0 -0
  225. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline1.js → perspective-viewer-8a8c7d5d696b43ae/inline2.js} +0 -0
  226. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline2.js → perspective-viewer-8a8c7d5d696b43ae/inline3.js} +0 -0
  227. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline4.js → perspective-viewer-8a8c7d5d696b43ae/inline5.js} +0 -0
@@ -0,0 +1,846 @@
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
+ //! The multi-panel model backing a single `<perspective-viewer>`.
14
+
15
+ use std::cell::RefCell;
16
+ use std::collections::HashSet;
17
+ use std::rc::Rc;
18
+
19
+ use perspective_client::Client;
20
+ use perspective_client::config::Filter;
21
+
22
+ use crate::renderer::Renderer;
23
+ use crate::session::Session;
24
+ use crate::utils::{EffectLedger, PubSub, Subscription};
25
+
26
+ /// A unique identifier for a [`Panel`] within a [`Workspace`].
27
+ #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
28
+ pub struct PanelId(String);
29
+
30
+ impl PanelId {
31
+ pub fn as_str(&self) -> &str {
32
+ &self.0
33
+ }
34
+ }
35
+
36
+ impl std::fmt::Display for PanelId {
37
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38
+ f.write_str(&self.0)
39
+ }
40
+ }
41
+
42
+ impl From<String> for PanelId {
43
+ fn from(value: String) -> Self {
44
+ Self(value)
45
+ }
46
+ }
47
+
48
+ impl From<&str> for PanelId {
49
+ fn from(value: &str) -> Self {
50
+ Self(value.to_owned())
51
+ }
52
+ }
53
+
54
+ /// The element-level global filter state (master/detail cross-filter): an
55
+ /// unattributed `restored` bucket (from whole-element `restore` — per-master
56
+ /// attribution is not persisted) plus one contribution per master panel, in
57
+ /// first-contribution order. The effective set is the ordered, deduped
58
+ /// flattening of both. Pure data — factored out of [`WorkspaceData`] so the
59
+ /// replace/dedup/removal semantics are unit-testable without engine handles.
60
+ #[derive(Default)]
61
+ struct GlobalFilterSet {
62
+ restored: Vec<Filter>,
63
+ contributions: Vec<(PanelId, Vec<Filter>)>,
64
+ }
65
+
66
+ impl GlobalFilterSet {
67
+ /// The effective set: `restored`, then each contribution in order,
68
+ /// deduped by clause equality.
69
+ fn flatten(&self) -> Vec<Filter> {
70
+ let mut flat: Vec<Filter> = Vec::new();
71
+ let all = self
72
+ .restored
73
+ .iter()
74
+ .chain(self.contributions.iter().flat_map(|(_, fs)| fs.iter()));
75
+
76
+ for filter in all {
77
+ if !flat.contains(filter) {
78
+ flat.push(filter.clone());
79
+ }
80
+ }
81
+
82
+ flat
83
+ }
84
+
85
+ /// Apply `f`, reporting whether the EFFECTIVE (flattened) set changed —
86
+ /// internal-only moves (e.g. a clause migrating buckets) don't count, so
87
+ /// observers re-render/re-query only on visible change.
88
+ fn with_change(&mut self, f: impl FnOnce(&mut Self)) -> bool {
89
+ let before = self.flatten();
90
+ f(self);
91
+ before != self.flatten()
92
+ }
93
+
94
+ /// Replace `id`'s contribution (a master's new selection REPLACES its
95
+ /// prior one — no plugin-remembered remove-lists). A non-empty selection
96
+ /// also drops the `restored` bucket, which is a stale snapshot of some
97
+ /// pre-save master's selection. Empty removes the entry (deselect).
98
+ fn set_contribution(&mut self, id: &PanelId, filters: Vec<Filter>) -> bool {
99
+ self.with_change(|s| {
100
+ if filters.is_empty() {
101
+ s.contributions.retain(|(pid, _)| pid != id);
102
+ } else {
103
+ s.restored.clear();
104
+ match s.contributions.iter_mut().find(|(pid, _)| pid == id) {
105
+ Some((_, fs)) => *fs = filters,
106
+ None => s.contributions.push((id.clone(), filters)),
107
+ }
108
+ }
109
+ })
110
+ }
111
+
112
+ /// Remove the flattened-view clause at `index` from EVERY bucket (a chip
113
+ /// stands for the clause, not one bucket's copy — removing it from only
114
+ /// one would resurface the duplicate). Returns whether the effective set
115
+ /// changed and the OWNING master panels of the removed clause (for
116
+ /// selection-state cleanup). Out-of-range is a no-op.
117
+ fn remove_clause(&mut self, index: usize) -> (bool, Vec<PanelId>) {
118
+ let Some(clause) = self.flatten().get(index).cloned() else {
119
+ return (false, Vec::new());
120
+ };
121
+
122
+ let owners = self
123
+ .contributions
124
+ .iter()
125
+ .filter(|(_, fs)| fs.contains(&clause))
126
+ .map(|(pid, _)| pid.clone())
127
+ .collect();
128
+
129
+ let changed = self.with_change(|s| {
130
+ s.restored.retain(|f| f != &clause);
131
+ for (_, fs) in s.contributions.iter_mut() {
132
+ fs.retain(|f| f != &clause);
133
+ }
134
+
135
+ s.contributions.retain(|(_, fs)| !fs.is_empty());
136
+ });
137
+
138
+ (changed, owners)
139
+ }
140
+
141
+ /// Drop both buckets, returning the contribution owners (for
142
+ /// selection-state cleanup).
143
+ fn clear(&mut self) -> (bool, Vec<PanelId>) {
144
+ let owners = self
145
+ .contributions
146
+ .iter()
147
+ .map(|(pid, _)| pid.clone())
148
+ .collect();
149
+
150
+ let changed = self.with_change(|s| {
151
+ s.restored.clear();
152
+ s.contributions.clear();
153
+ });
154
+
155
+ (changed, owners)
156
+ }
157
+
158
+ /// Whole-element `restore`: replace everything with an unattributed set.
159
+ fn set_restored(&mut self, filters: Vec<Filter>) -> bool {
160
+ self.with_change(|s| {
161
+ s.contributions.clear();
162
+ s.restored = filters;
163
+ })
164
+ }
165
+ }
166
+
167
+ /// A single, fully-independent viewer-like unit within a [`Workspace`]: its own
168
+ /// [`Session`] (table binding + view config) and [`Renderer`] (active plugin).
169
+ #[derive(Clone)]
170
+ pub struct Panel {
171
+ pub id: PanelId,
172
+ pub session: Session,
173
+ pub renderer: Renderer,
174
+
175
+ /// Subscriptions owned for this panel's lifetime: its redraw subscription
176
+ /// (`table_updated` → redraw) plus its custom-event fanout
177
+ /// (`wire_panel_events`). Held here — not on the element — so they drop
178
+ /// exactly when the panel is removed from the [`Workspace`], with no
179
+ /// separate add/remove bookkeeping.
180
+ _subs: Rc<Vec<Subscription>>,
181
+ }
182
+
183
+ impl Panel {
184
+ pub fn new(id: PanelId, session: Session, renderer: Renderer, subs: Vec<Subscription>) -> Self {
185
+ Self {
186
+ id,
187
+ session,
188
+ renderer,
189
+ _subs: Rc::new(subs),
190
+ }
191
+ }
192
+ }
193
+
194
+ /// The multi-panel model backing a single `<perspective-viewer>`. See the
195
+ /// module docs for the Phase 1 (single-panel) invariants.
196
+ #[derive(Clone)]
197
+ pub struct Workspace(Rc<RefCell<WorkspaceData>>);
198
+
199
+ impl PartialEq for Workspace {
200
+ fn eq(&self, other: &Self) -> bool {
201
+ Rc::ptr_eq(&self.0, &other.0)
202
+ }
203
+ }
204
+
205
+ struct WorkspaceData {
206
+ /// Panels in insertion order.
207
+ panels: Vec<Panel>,
208
+
209
+ /// The currently active/selected panel (a live panel in `panels`).
210
+ active: Option<PanelId>,
211
+
212
+ /// The first [`Client`] loaded via the element's `load()`.
213
+ default_client: Option<Client>,
214
+
215
+ /// Every [`Client`] ever loaded into this element (registration order.
216
+ clients: Vec<Client>,
217
+
218
+ /// Fires once per genuinely-new [`Client`] registration (post-dedup) —
219
+ /// the element subscribes each new client's hosted-tables updates for
220
+ /// reactive table binding (`tasks::table_lifecycle`).
221
+ client_registered: Rc<PubSub<Client>>,
222
+
223
+ /// Monotonic counter backing [`Workspace::generate_id`].
224
+ next_id: usize,
225
+
226
+ /// Panels designated as master/detail filter sources.
227
+ masters: HashSet<PanelId>,
228
+
229
+ /// The element-level global filters.
230
+ filters: GlobalFilterSet,
231
+ filters_changed: Rc<PubSub<()>>,
232
+
233
+ /// A layout tree staged by whole-element `restore`.
234
+ pending_layout: Option<crate::js::Layout>,
235
+
236
+ /// Freshly-created panels withheld from the `<regular-layout>`.
237
+ staged: HashSet<PanelId>,
238
+ staged_changed: Rc<PubSub<()>>,
239
+
240
+ /// The RESERVED first panel of a pending `load()`.
241
+ reserved: Option<Panel>,
242
+
243
+ /// In-flight effects (public mutators + scheduled internal flows),
244
+ /// drained by `flush()` — see [`EffectLedger`].
245
+ effects: EffectLedger,
246
+ }
247
+
248
+ impl Default for Workspace {
249
+ fn default() -> Self {
250
+ Self::new()
251
+ }
252
+ }
253
+
254
+ impl Workspace {
255
+ /// Create an EMPTY `Workspace`.
256
+ pub fn new() -> Self {
257
+ Self(Rc::new(RefCell::new(WorkspaceData {
258
+ panels: Vec::new(),
259
+ active: None,
260
+ default_client: None,
261
+ clients: Vec::new(),
262
+ client_registered: Rc::new(PubSub::default()),
263
+ next_id: 0,
264
+ masters: HashSet::new(),
265
+ filters: GlobalFilterSet::default(),
266
+ filters_changed: Rc::new(PubSub::default()),
267
+ pending_layout: None,
268
+ staged: HashSet::new(),
269
+ staged_changed: Rc::new(PubSub::default()),
270
+ reserved: None,
271
+ effects: EffectLedger::default(),
272
+ })))
273
+ }
274
+
275
+ /// The element's in-flight effect ledger (see [`EffectLedger`]).
276
+ pub fn effects(&self) -> EffectLedger {
277
+ self.0.borrow().effects.clone()
278
+ }
279
+
280
+ /// Stage a layout tree for `MainPanel` to apply at its next `rendered`
281
+ /// pass (see `WorkspaceData::pending_layout`).
282
+ pub fn set_pending_layout(&self, layout: crate::js::Layout) {
283
+ self.0.borrow_mut().pending_layout = Some(layout);
284
+ }
285
+
286
+ /// Take (consume) the staged layout tree, if any.
287
+ pub fn take_pending_layout(&self) -> Option<crate::js::Layout> {
288
+ self.0.borrow_mut().pending_layout.take()
289
+ }
290
+
291
+ /// A handle to the `staged_changed` PubSub (see
292
+ /// [`WorkspaceData::staged_changed`]).
293
+ pub fn staged_changed(&self) -> Rc<PubSub<()>> {
294
+ self.0.borrow().staged_changed.clone()
295
+ }
296
+
297
+ /// Mark a freshly-created panel STAGED (see [`WorkspaceData::staged`]).
298
+ /// Emits `staged_changed` — outside the borrow.
299
+ pub fn stage_panel(&self, id: &PanelId) {
300
+ let pubsub = {
301
+ let mut data = self.0.borrow_mut();
302
+ data.staged.insert(id.clone());
303
+ data.staged_changed.clone()
304
+ };
305
+
306
+ pubsub.emit(());
307
+ }
308
+
309
+ /// Promote a staged panel toward layout insertion, returning whether it
310
+ /// was still staged — restore completion and the staging deadline RACE
311
+ /// to promote, and only the winner proceeds. Emits `staged_changed` —
312
+ /// outside the borrow — iff the set changed.
313
+ pub fn clear_staged(&self, id: &PanelId) -> bool {
314
+ let (removed, pubsub) = {
315
+ let mut data = self.0.borrow_mut();
316
+ (data.staged.remove(id), data.staged_changed.clone())
317
+ };
318
+
319
+ if removed {
320
+ pubsub.emit(());
321
+ }
322
+
323
+ removed
324
+ }
325
+
326
+ /// Whether `id` is a staged (created, not yet layout-inserted) panel.
327
+ pub fn is_staged(&self, id: &PanelId) -> bool {
328
+ self.0.borrow().staged.contains(id)
329
+ }
330
+
331
+ /// The EFFECTIVE element-level global filters (master/detail
332
+ /// cross-filter): the restored bucket then each master's contribution,
333
+ /// deduped, as a snapshot clone.
334
+ pub fn global_filters(&self) -> Vec<Filter> {
335
+ self.0.borrow().filters.flatten()
336
+ }
337
+
338
+ /// A handle to the `filters_changed` PubSub (fires after any change to the
339
+ /// global filter set).
340
+ pub fn filters_changed(&self) -> Rc<PubSub<()>> {
341
+ self.0.borrow().filters_changed.clone()
342
+ }
343
+
344
+ /// Run `f` against the filter set inside the borrow, then emit
345
+ /// `filters_changed` — outside it — iff the effective set changed.
346
+ /// Returns `f`'s auxiliary payload.
347
+ fn mutate_filters<T>(&self, f: impl FnOnce(&mut GlobalFilterSet) -> (bool, T)) -> T {
348
+ let (changed, payload, pubsub) = {
349
+ let mut data = self.0.borrow_mut();
350
+ let (changed, payload) = f(&mut data.filters);
351
+ (changed, payload, data.filters_changed.clone())
352
+ };
353
+
354
+ if changed {
355
+ pubsub.emit(());
356
+ }
357
+
358
+ payload
359
+ }
360
+
361
+ /// Replace the global filter set with an UNATTRIBUTED (restored) bucket,
362
+ /// dropping every master contribution — the whole-element `restore`
363
+ /// entry point. Callers push the new set into panel sessions via
364
+ /// `tasks::apply_global_filters`.
365
+ pub fn set_global_filters(&self, filters: Vec<Filter>) {
366
+ self.mutate_filters(|s| (s.set_restored(filters), ()));
367
+ }
368
+
369
+ /// Replace master `id`'s contribution with a new selection (empty =
370
+ /// deselect). See [`GlobalFilterSet::set_contribution`].
371
+ pub fn set_contribution(&self, id: &PanelId, filters: Vec<Filter>) {
372
+ self.mutate_filters(|s| (s.set_contribution(id, filters), ()));
373
+ }
374
+
375
+ /// Drop master `id`'s contribution (deselect / demote / close).
376
+ pub fn clear_contribution(&self, id: &PanelId) {
377
+ self.mutate_filters(|s| (s.set_contribution(id, Vec::new()), ()));
378
+ }
379
+
380
+ /// Remove the effective-set clause at `index` (the `GlobalFilterBar`
381
+ /// chip ×), returning the OWNING master panels of the removed clause so
382
+ /// the caller can clear their selection state
383
+ /// (`tasks::clear_master_selections`).
384
+ pub fn remove_global_filter(&self, index: usize) -> Vec<PanelId> {
385
+ self.mutate_filters(|s| s.remove_clause(index))
386
+ }
387
+
388
+ /// Drop the entire global filter set (the `GlobalFilterBar` "Clear" /
389
+ /// element `reset()`), returning the contribution owners for
390
+ /// selection-state cleanup. Master ROLES are untouched — like the
391
+ /// layout, they are workspace structure, not filter state.
392
+ pub fn clear_global_filters(&self) -> Vec<PanelId> {
393
+ self.mutate_filters(GlobalFilterSet::clear)
394
+ }
395
+
396
+ /// The master (filter-source) panel ids, SORTED — `save()` serializes
397
+ /// this, and a `HashSet`'s per-instance iteration order would make
398
+ /// consecutive `save()` outputs byte-unstable (cf. the `panels`
399
+ /// `BTreeMap`).
400
+ pub fn masters(&self) -> Vec<PanelId> {
401
+ let mut masters: Vec<_> = self.0.borrow().masters.iter().cloned().collect();
402
+ masters.sort();
403
+ masters
404
+ }
405
+
406
+ /// Replace the master role set — the whole-element `restore` entry point
407
+ /// (ids already remapped to the fresh panel ids).
408
+ pub fn set_masters(&self, ids: Vec<PanelId>) {
409
+ self.0.borrow_mut().masters = ids.into_iter().collect();
410
+ }
411
+
412
+ /// Whether `id` is a master (filter-source) panel.
413
+ pub fn is_master(&self, id: &PanelId) -> bool {
414
+ self.0.borrow().masters.contains(id)
415
+ }
416
+
417
+ /// Toggle `id`'s master/detail role, returning the new state (`true` =
418
+ /// master).
419
+ pub fn toggle_master(&self, id: &PanelId) -> bool {
420
+ let mut data = self.0.borrow_mut();
421
+ if data.masters.remove(id) {
422
+ false
423
+ } else {
424
+ data.masters.insert(id.clone());
425
+ true
426
+ }
427
+ }
428
+
429
+ /// Generate a fresh [`PanelId`], unique within this workspace.
430
+ pub fn generate_id(&self) -> PanelId {
431
+ let mut data = self.0.borrow_mut();
432
+ let n = data.next_id;
433
+ data.next_id += 1;
434
+ PanelId(format!("PERSPECTIVE_GENERATED_ID_{n}"))
435
+ }
436
+
437
+ /// The id of the active panel, or `None` with zero panels.
438
+ pub fn active_id(&self) -> Option<PanelId> {
439
+ self.0.borrow().active.clone()
440
+ }
441
+
442
+ /// The active [`Panel`] (clone; shares engine state), or `None` with zero
443
+ /// panels.
444
+ pub fn active_panel(&self) -> Option<Panel> {
445
+ let data = self.0.borrow();
446
+ let active = data.active.as_ref()?;
447
+ data.panels.iter().find(|p| &p.id == active).cloned()
448
+ }
449
+
450
+ /// The active panel's [`Session`], or `None` with zero panels.
451
+ pub fn active_session(&self) -> Option<Session> {
452
+ self.active_panel().map(|p| p.session)
453
+ }
454
+
455
+ /// The active panel's [`Renderer`], or `None` with zero panels.
456
+ pub fn active_renderer(&self) -> Option<Renderer> {
457
+ self.active_panel().map(|p| p.renderer)
458
+ }
459
+
460
+ /// Look up a [`Panel`] by id.
461
+ pub fn panel(&self, id: &PanelId) -> Option<Panel> {
462
+ self.0.borrow().panels.iter().find(|p| &p.id == id).cloned()
463
+ }
464
+
465
+ /// Resolve a panel by id, or the active panel when `id` is `None` — the
466
+ /// shape the public `*Panel(name?)` delegating methods will use.
467
+ pub fn panel_or_active(&self, id: Option<&PanelId>) -> Option<Panel> {
468
+ match id {
469
+ Some(id) => self.panel(id),
470
+ None => self.active_panel(),
471
+ }
472
+ }
473
+
474
+ /// Snapshot of all [`Panel`]s, in insertion order — the canonical
475
+ /// fan-out source (fan-outs collect per-panel results; they never
476
+ /// sequential-abort on one panel's error).
477
+ pub fn panels(&self) -> Vec<Panel> {
478
+ self.0.borrow().panels.to_vec()
479
+ }
480
+
481
+ /// The number of PLACED panels (panels minus staged) — the single
482
+ /// source for panel-count chrome (`single`/`multi`, closable,
483
+ /// draggable, `only-child`).
484
+ pub fn placed_count(&self) -> usize {
485
+ let data = self.0.borrow();
486
+ data.panels
487
+ .iter()
488
+ .filter(|p| !data.staged.contains(&p.id))
489
+ .count()
490
+ }
491
+
492
+ /// All panel ids, in insertion order.
493
+ pub fn panel_ids(&self) -> Vec<PanelId> {
494
+ self.0
495
+ .borrow()
496
+ .panels
497
+ .iter()
498
+ .map(|p| p.id.clone())
499
+ .collect()
500
+ }
501
+
502
+ /// The number of panels (may be `0` — an empty stage).
503
+ pub fn len(&self) -> usize {
504
+ self.0.borrow().panels.len()
505
+ }
506
+
507
+ /// Whether the element has zero panels.
508
+ pub fn is_empty(&self) -> bool {
509
+ self.0.borrow().panels.is_empty()
510
+ }
511
+
512
+ /// Append a [`Panel`]. When the element had zero panels, the inserted panel
513
+ /// becomes the active one (there is no other candidate).
514
+ pub fn insert_panel(&self, panel: Panel) {
515
+ let mut data = self.0.borrow_mut();
516
+ if data.active.is_none() {
517
+ data.active = Some(panel.id.clone());
518
+ }
519
+
520
+ panel
521
+ .renderer
522
+ .set_active_flag(data.active.as_ref() == Some(&panel.id));
523
+ data.panels.push(panel);
524
+ Self::sync_solo_flags(&data);
525
+ }
526
+
527
+ /// Hold `panel` in the reservation slot (see [`WorkspaceData::reserved`]):
528
+ /// NOT placed, invisible to every placed-panel consumer, awaiting a
529
+ /// pending `load()`'s payload classification or an interim claimant.
530
+ pub fn reserve_panel(&self, panel: Panel) {
531
+ self.0.borrow_mut().reserved = Some(panel);
532
+ }
533
+
534
+ /// The reservation slot's current occupant (shared handles), without a
535
+ /// transfer — a second `load()` on a still-empty element adopts the same
536
+ /// reservation rather than creating a competing one.
537
+ pub fn reserved_panel(&self) -> Option<Panel> {
538
+ self.0.borrow().reserved.clone()
539
+ }
540
+
541
+ /// PLACE the reserved panel: transfer it out of the reservation slot into
542
+ /// the placed set ([`Self::insert_panel`] — auto-activating on an empty
543
+ /// element). `None` when the slot is empty (no reservation, or the other
544
+ /// actor transferred first).
545
+ pub fn claim_reserved(&self) -> Option<Panel> {
546
+ let panel = self.0.borrow_mut().reserved.take()?;
547
+ self.insert_panel(panel.clone());
548
+ Some(panel)
549
+ }
550
+
551
+ /// DISCARD the reserved panel: transfer it out of the reservation slot
552
+ /// WITHOUT placing it, for disposal (an inert `Client` payload with no
553
+ /// claimant, or teardown draining). `None` when the slot is empty.
554
+ pub fn take_reserved(&self) -> Option<Panel> {
555
+ self.0.borrow_mut().reserved.take()
556
+ }
557
+
558
+ /// Sync every panel renderer's solo (lone-panel) flag with the current
559
+ /// panel count — called from each count-changing mutation site (data
560
+ /// only; the `single`/`multi` CSS classes land inside each panel's next
561
+ /// locked plugin dispatch — see `Renderer::stamp_active`).
562
+ fn sync_solo_flags(data: &WorkspaceData) {
563
+ let is_solo = data.panels.len() == 1;
564
+ for panel in data.panels.iter() {
565
+ panel.renderer.set_solo_flag(is_solo);
566
+ }
567
+ }
568
+
569
+ /// Remove a [`Panel`] by id, returning it if present.
570
+ /// Remove a [`Panel`] by id, returning it if present. Model cleanup is
571
+ /// structural: EVERY removal path (close, whole-element restore's batch
572
+ /// replacement) drops the panel's master role and its global-filter
573
+ /// contribution here, so neither can outlive the panel.
574
+ pub fn remove_panel(&self, id: &PanelId) -> Option<Panel> {
575
+ let (removed, changed, pubsub, staged_removed, staged_pubsub) = {
576
+ let mut data = self.0.borrow_mut();
577
+ data.masters.remove(id);
578
+ let staged_removed = data.staged.remove(id);
579
+ let changed = data.filters.set_contribution(id, Vec::new());
580
+ let removed = data
581
+ .panels
582
+ .iter()
583
+ .position(|p| &p.id == id)
584
+ .map(|idx| data.panels.remove(idx));
585
+
586
+ if data.active.as_ref() == Some(id) {
587
+ data.active = None;
588
+ }
589
+
590
+ Self::sync_solo_flags(&data);
591
+ (
592
+ removed,
593
+ changed,
594
+ data.filters_changed.clone(),
595
+ staged_removed,
596
+ data.staged_changed.clone(),
597
+ )
598
+ };
599
+
600
+ if changed {
601
+ pubsub.emit(());
602
+ }
603
+
604
+ if staged_removed {
605
+ staged_pubsub.emit(());
606
+ }
607
+
608
+ removed
609
+ }
610
+
611
+ /// Set the active panel. Returns `false` (no-op) if `id` is not a known
612
+ /// panel.
613
+ pub fn set_active(&self, id: PanelId) -> bool {
614
+ let mut data = self.0.borrow_mut();
615
+ if data.panels.iter().any(|p| p.id == id) {
616
+ data.active = Some(id);
617
+ for panel in data.panels.iter() {
618
+ panel
619
+ .renderer
620
+ .set_active_flag(data.active.as_ref() == Some(&panel.id));
621
+ }
622
+
623
+ true
624
+ } else {
625
+ false
626
+ }
627
+ }
628
+
629
+ /// The default [`Client`], if one has been loaded.
630
+ pub fn default_client(&self) -> Option<Client> {
631
+ self.0.borrow().default_client.clone()
632
+ }
633
+
634
+ /// The active panel's bound [`Client`], if any — the default target of a
635
+ /// no-argument `eject()`.
636
+ pub fn active_client(&self) -> Option<Client> {
637
+ self.active_panel().and_then(|p| p.session.get_client())
638
+ }
639
+
640
+ /// The ids of every panel whose session is bound to the [`Client`] named
641
+ /// `name` (client names are globally unique), in insertion order.
642
+ pub fn panels_for_client(&self, name: &str) -> Vec<PanelId> {
643
+ self.0
644
+ .borrow()
645
+ .panels
646
+ .iter()
647
+ .filter(|p| p.session.get_client().is_some_and(|c| c.get_name() == name))
648
+ .map(|p| p.id.clone())
649
+ .collect()
650
+ }
651
+
652
+ /// Drop the [`Client`] named `name` from the loaded-clients registry, and
653
+ /// clear the default-client designation if it referred to this client.
654
+ /// Callers must have already removed every panel bound to it (see
655
+ /// [`Workspace::panels_for_client`]) — `clients()` unions in live panel
656
+ /// sessions, so a lingering panel would resurrect it.
657
+ pub fn remove_client(&self, name: &str) {
658
+ let mut data = self.0.borrow_mut();
659
+ data.clients.retain(|c| c.get_name() != name);
660
+ if data
661
+ .default_client
662
+ .as_ref()
663
+ .is_some_and(|c| c.get_name() == name)
664
+ {
665
+ data.default_client = None;
666
+ }
667
+ }
668
+
669
+ /// Record the default [`Client`] if not already set (first-wins, matching
670
+ /// the "first `Client` passed to `load()` is the default" rule). Always
671
+ /// registers the client (see [`Workspace::register_client`]) — first-wins
672
+ /// applies only to the *default* designation.
673
+ pub fn set_default_client(&self, client: Client) {
674
+ self.register_client(client.clone());
675
+ let mut data = self.0.borrow_mut();
676
+ if data.default_client.is_none() {
677
+ data.default_client = Some(client);
678
+ }
679
+ }
680
+
681
+ /// Add a [`Client`] to the loaded-clients registry, if a client with the
682
+ /// same (globally unique) name isn't already present. Emits
683
+ /// `client_registered` — outside the borrow — for a genuinely-new client.
684
+ pub fn register_client(&self, client: Client) {
685
+ let pubsub = {
686
+ let mut data = self.0.borrow_mut();
687
+ if data
688
+ .clients
689
+ .iter()
690
+ .any(|c| c.get_name() == client.get_name())
691
+ {
692
+ return;
693
+ }
694
+
695
+ data.clients.push(client.clone());
696
+ data.client_registered.clone()
697
+ };
698
+
699
+ pubsub.emit(client);
700
+ }
701
+
702
+ /// A handle to the `client_registered` PubSub (see
703
+ /// [`WorkspaceData::client_registered`]).
704
+ pub fn client_registered(&self) -> Rc<PubSub<Client>> {
705
+ self.0.borrow().client_registered.clone()
706
+ }
707
+
708
+ /// All loaded [`Client`]s: the registry, unioned with every panel
709
+ /// session's bound client (belt-and-braces for any binding path that
710
+ /// bypasses registration), deduped by name in registration order.
711
+ pub fn clients(&self) -> Vec<Client> {
712
+ let data = self.0.borrow();
713
+ let mut clients = data.clients.clone();
714
+ for panel in &data.panels {
715
+ if let Some(client) = panel.session.get_client()
716
+ && !clients.iter().any(|c| c.get_name() == client.get_name())
717
+ {
718
+ clients.push(client);
719
+ }
720
+ }
721
+
722
+ clients
723
+ }
724
+
725
+ /// Resolve which loaded [`Client`] a panel should bind to serve the `Table`
726
+ /// named `table_name`, given its `current` binding (if any).
727
+ pub async fn resolve_client_for_table(
728
+ &self,
729
+ table_name: &str,
730
+ current: Option<&Client>,
731
+ ) -> Option<Client> {
732
+ let clients = self.clients();
733
+
734
+ // A single bound client is already correct — no probe, no rebind.
735
+ if current.is_some() && clients.len() <= 1 {
736
+ return None;
737
+ }
738
+
739
+ let mut host = None;
740
+ for client in &clients {
741
+ if let Ok(names) = client.get_hosted_table_names().await
742
+ && names.iter().any(|n| n.as_str() == table_name)
743
+ {
744
+ host = Some(client.clone());
745
+ break;
746
+ }
747
+ }
748
+
749
+ match host {
750
+ Some(host) if current.map(|c| c.get_name()) != Some(host.get_name()) => Some(host),
751
+ Some(_) => None,
752
+ // No host: seed an unbound session with the default client; leave a
753
+ // bound session as-is.
754
+ None => current.is_none().then(|| self.default_client()).flatten(),
755
+ }
756
+ }
757
+ }
758
+
759
+ #[cfg(test)]
760
+ mod tests {
761
+ use perspective_client::config::FilterTerm;
762
+
763
+ use super::*;
764
+
765
+ fn f(col: &str, term: &str) -> Filter {
766
+ Filter::new(col, "==", FilterTerm::Scalar(term.into()))
767
+ }
768
+
769
+ fn p(id: &str) -> PanelId {
770
+ PanelId::from(id)
771
+ }
772
+
773
+ #[test]
774
+ fn flatten_orders_and_dedups_across_buckets() {
775
+ let mut s = GlobalFilterSet::default();
776
+ assert!(s.set_restored(vec![f("a", "1"), f("b", "2")]));
777
+ // A non-empty contribution drops the restored bucket entirely.
778
+ assert!(s.set_contribution(&p("x"), vec![f("b", "2"), f("c", "3")]));
779
+ assert_eq!(s.flatten(), vec![f("b", "2"), f("c", "3")]);
780
+ assert!(s.set_contribution(&p("y"), vec![f("c", "3"), f("d", "4")]));
781
+ assert_eq!(s.flatten(), vec![f("b", "2"), f("c", "3"), f("d", "4")]);
782
+ }
783
+
784
+ #[test]
785
+ fn set_contribution_replaces_not_accumulates() {
786
+ let mut s = GlobalFilterSet::default();
787
+ assert!(s.set_contribution(&p("x"), vec![f("a", "1")]));
788
+ assert!(s.set_contribution(&p("x"), vec![f("a", "2")]));
789
+ assert_eq!(s.flatten(), vec![f("a", "2")]);
790
+ // Re-selecting the same value reports no visible change.
791
+ assert!(!s.set_contribution(&p("x"), vec![f("a", "2")]));
792
+ // Empty = deselect: the entry is removed.
793
+ assert!(s.set_contribution(&p("x"), Vec::new()));
794
+ assert_eq!(s.flatten(), Vec::<Filter>::new());
795
+ }
796
+
797
+ #[test]
798
+ fn contributions_are_per_panel() {
799
+ let mut s = GlobalFilterSet::default();
800
+ s.set_contribution(&p("x"), vec![f("a", "1")]);
801
+ s.set_contribution(&p("y"), vec![f("b", "2")]);
802
+ // Clearing one master's contribution leaves the other's intact.
803
+ assert!(s.set_contribution(&p("x"), Vec::new()));
804
+ assert_eq!(s.flatten(), vec![f("b", "2")]);
805
+ }
806
+
807
+ #[test]
808
+ fn remove_clause_removes_from_every_bucket_and_reports_owners() {
809
+ let mut s = GlobalFilterSet::default();
810
+ s.set_contribution(&p("x"), vec![f("a", "1"), f("b", "2")]);
811
+ s.set_contribution(&p("y"), vec![f("a", "1")]);
812
+ // flatten = [a==1, b==2]; removing index 0 must drop BOTH copies of
813
+ // a==1 (owners x AND y), and the now-empty "y" entry with it.
814
+ let (changed, owners) = s.remove_clause(0);
815
+ assert!(changed);
816
+ assert_eq!(owners, vec![p("x"), p("y")]);
817
+ assert_eq!(s.flatten(), vec![f("b", "2")]);
818
+ // Out-of-range is a no-op.
819
+ assert_eq!(s.remove_clause(5), (false, Vec::new()));
820
+ }
821
+
822
+ #[test]
823
+ fn restored_bucket_survives_deselect_but_not_selection() {
824
+ let mut s = GlobalFilterSet::default();
825
+ s.set_restored(vec![f("a", "1")]);
826
+ // A deselect (empty contribution) does NOT drop the restored bucket.
827
+ assert!(!s.set_contribution(&p("x"), Vec::new()));
828
+ assert_eq!(s.flatten(), vec![f("a", "1")]);
829
+ // A real selection replaces it.
830
+ assert!(s.set_contribution(&p("x"), vec![f("b", "2")]));
831
+ assert_eq!(s.flatten(), vec![f("b", "2")]);
832
+ assert!(s.restored.is_empty());
833
+ }
834
+
835
+ #[test]
836
+ fn clear_drops_everything_and_reports_owners() {
837
+ let mut s = GlobalFilterSet::default();
838
+ s.set_restored(vec![f("a", "1")]);
839
+ s.set_contribution(&p("x"), vec![f("b", "2")]);
840
+ let (changed, owners) = s.clear();
841
+ assert!(changed);
842
+ assert_eq!(owners, vec![p("x")]);
843
+ assert_eq!(s.flatten(), Vec::<Filter>::new());
844
+ assert_eq!(s.clear(), (false, Vec::new()));
845
+ }
846
+ }