@perspective-dev/viewer 5.1.0 → 5.3.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 (245) hide show
  1. package/dist/cdn/perspective-viewer.js +9 -9
  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/docs/perspective-docs.json +1 -0
  24. package/dist/esm/column-format.d.ts +19 -28
  25. package/dist/esm/perspective-viewer.d.ts +3 -0
  26. package/dist/esm/perspective-viewer.inline.js +9 -9
  27. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  28. package/dist/esm/perspective-viewer.js +9 -9
  29. package/dist/esm/perspective-viewer.js.map +4 -4
  30. package/dist/esm/plugin.d.ts +10 -0
  31. package/dist/esm/providers.d.ts +79 -0
  32. package/dist/esm/ts-rs/CompactDisplay.d.ts +5 -0
  33. package/dist/esm/ts-rs/CurrencyCode.d.ts +1 -0
  34. package/dist/esm/ts-rs/CurrencyDisplay.d.ts +1 -0
  35. package/dist/esm/ts-rs/CurrencyNumberFormatStyle.d.ts +8 -0
  36. package/dist/esm/ts-rs/CurrencySign.d.ts +1 -0
  37. package/dist/esm/ts-rs/CustomDatetimeFormat.d.ts +1 -0
  38. package/dist/esm/ts-rs/CustomDatetimeStyleConfig.d.ts +54 -0
  39. package/dist/esm/ts-rs/CustomNumberFormatConfig.d.ts +34 -0
  40. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +1 -0
  41. package/dist/esm/ts-rs/DatetimeFormatType.d.ts +6 -0
  42. package/dist/esm/ts-rs/FormatMode.d.ts +1 -0
  43. package/dist/esm/ts-rs/FormatUnit.d.ts +1 -0
  44. package/dist/esm/ts-rs/Notation.d.ts +17 -0
  45. package/dist/esm/ts-rs/NumberFormatStyle.d.ts +18 -0
  46. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +42 -2
  47. package/dist/esm/ts-rs/PluginStaticConfig.d.ts +35 -0
  48. package/dist/esm/ts-rs/RestoreOptions.d.ts +19 -0
  49. package/dist/esm/ts-rs/RoundingMode.d.ts +1 -0
  50. package/dist/esm/ts-rs/RoundingPriority.d.ts +1 -0
  51. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  52. package/dist/esm/ts-rs/SignDisplay.d.ts +1 -0
  53. package/dist/esm/ts-rs/SimpleDatetimeFormat.d.ts +1 -0
  54. package/dist/esm/ts-rs/SimpleDatetimeStyleConfig.d.ts +24 -0
  55. package/dist/esm/ts-rs/SplitRollupMode.d.ts +9 -0
  56. package/dist/esm/ts-rs/StringColorMode.d.ts +1 -0
  57. package/dist/esm/ts-rs/TrailingZeroDisplay.d.ts +1 -0
  58. package/dist/esm/ts-rs/Unit.d.ts +1 -0
  59. package/dist/esm/ts-rs/UnitDisplay.d.ts +1 -0
  60. package/dist/esm/ts-rs/UnitNumberFormatStyle.d.ts +6 -0
  61. package/dist/esm/ts-rs/UseGrouping.d.ts +1 -0
  62. package/dist/esm/ts-rs/ViewerConfig.d.ts +42 -2
  63. package/dist/esm/ts-rs/ViewerConfigInitial.d.ts +175 -0
  64. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +63 -0
  65. package/dist/esm/ts-rs/WindowSpec.d.ts +3 -2
  66. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  67. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +20 -9
  68. package/dist/wasm/perspective-viewer.d.ts +157 -41
  69. package/dist/wasm/perspective-viewer.js +452 -151
  70. package/dist/wasm/perspective-viewer.wasm +0 -0
  71. package/dist/wasm/perspective-viewer.wasm.d.ts +22 -17
  72. package/package.json +2 -1
  73. package/src/css/_viewer_bundle.css +1 -0
  74. package/src/css/column-selector.css +8 -0
  75. package/src/css/column-settings-panel.css +9 -5
  76. package/src/css/column-style.css +209 -31
  77. package/src/css/config-selector.css +38 -3
  78. package/src/css/dom/select.css +2 -1
  79. package/src/css/form/code-editor.css +6 -7
  80. package/src/css/form/mirrored-textarea.css +81 -0
  81. package/src/css/viewer.css +328 -0
  82. package/src/rust/agent/client/mod.rs +19 -0
  83. package/src/rust/agent/client/protocol.rs +320 -0
  84. package/src/rust/agent/client/transport.rs +287 -0
  85. package/src/rust/agent/client/turn.rs +174 -0
  86. package/src/rust/agent/config.rs +159 -0
  87. package/src/rust/agent/docs.rs +293 -0
  88. package/src/rust/agent/markdown/block.rs +509 -0
  89. package/src/rust/agent/markdown/entity.rs +52 -0
  90. package/src/rust/agent/markdown/events.rs +71 -0
  91. package/src/rust/agent/markdown/inline.rs +497 -0
  92. package/src/rust/agent/markdown/mod.rs +466 -0
  93. package/src/rust/agent/mod.rs +23 -0
  94. package/src/rust/agent/preamble.md +16 -0
  95. package/src/rust/agent/runtime.rs +201 -0
  96. package/src/rust/agent/slot.rs +236 -0
  97. package/src/rust/agent/tools.rs +1141 -0
  98. package/src/rust/components/chat_panel.rs +289 -0
  99. package/src/rust/components/column_selector/active_column.rs +2 -2
  100. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  101. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  102. package/src/rust/components/column_selector/config_selector.rs +84 -7
  103. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  104. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  105. package/src/rust/components/column_selector.rs +55 -4
  106. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  107. package/src/rust/components/column_settings_sidebar/style_tab.rs +262 -225
  108. package/src/rust/components/column_settings_sidebar.rs +88 -69
  109. package/src/rust/components/containers/dragdrop_list.rs +37 -0
  110. package/src/rust/components/editable_header.rs +85 -144
  111. package/src/rust/components/expression_editor.rs +9 -16
  112. package/src/rust/components/form/code_editor.rs +29 -28
  113. package/src/rust/components/form/debug.rs +9 -5
  114. package/src/rust/components/form/mirrored_textarea.rs +165 -0
  115. package/src/rust/components/form/mod.rs +4 -1
  116. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  117. package/src/rust/components/form/named_value_picker.rs +86 -0
  118. package/src/rust/components/form/palette_selector.rs +440 -0
  119. package/src/rust/components/main_panel/msg.rs +2 -2
  120. package/src/rust/components/main_panel/reconcile.rs +4 -4
  121. package/src/rust/components/mod.rs +2 -0
  122. package/src/rust/components/plugin_tab.rs +4 -28
  123. package/src/rust/components/settings_panel.rs +105 -22
  124. package/src/rust/components/viewer/msg.rs +46 -7
  125. package/src/rust/components/viewer/panels.rs +17 -20
  126. package/src/rust/components/viewer/render.rs +10 -3
  127. package/src/rust/components/viewer/settings.rs +163 -23
  128. package/src/rust/components/viewer/snapshots.rs +46 -21
  129. package/src/rust/components/viewer/wiring.rs +14 -25
  130. package/src/rust/components/viewer.rs +12 -8
  131. package/src/rust/components/window_editor.rs +75 -103
  132. package/src/rust/config/column_config_schema.rs +100 -41
  133. package/src/rust/config/css_scale.rs +882 -0
  134. package/src/rust/config/datetime_column_style/custom.rs +17 -0
  135. package/src/rust/config/datetime_column_style/simple.rs +10 -0
  136. package/src/rust/config/mod.rs +2 -0
  137. package/src/rust/config/number_string_format.rs +33 -0
  138. package/src/rust/config/options.rs +28 -0
  139. package/src/rust/config/plugin_static_config.rs +120 -5
  140. package/src/rust/config/viewer_config.rs +216 -3
  141. package/src/rust/config/workspace_config.rs +138 -10
  142. package/src/rust/custom_elements/viewer.rs +369 -119
  143. package/src/rust/custom_events.rs +43 -1
  144. package/src/rust/js/regular_layout.rs +3 -3
  145. package/src/rust/lib.rs +15 -0
  146. package/src/rust/presentation/column_locator.rs +19 -5
  147. package/src/rust/presentation.rs +200 -36
  148. package/src/rust/queries/column_locator.rs +74 -44
  149. package/src/rust/queries/get_viewer_config.rs +2 -1
  150. package/src/rust/queries/mod.rs +2 -0
  151. package/src/rust/queries/palette_set.rs +82 -0
  152. package/src/rust/queries/plugin_column_styles.rs +6 -2
  153. package/src/rust/renderer/dispatch.rs +16 -4
  154. package/src/rust/renderer/plugin_config.rs +309 -47
  155. package/src/rust/renderer.rs +15 -42
  156. package/src/rust/session/column_defaults_update.rs +32 -7
  157. package/src/rust/session/metadata.rs +23 -21
  158. package/src/rust/session/props.rs +1 -0
  159. package/src/rust/session/replace_expression_update.rs +1 -0
  160. package/src/rust/session.rs +9 -6
  161. package/src/rust/tasks/create_panel.rs +46 -49
  162. package/src/rust/tasks/edit_expression.rs +19 -23
  163. package/src/rust/tasks/edit_window.rs +9 -11
  164. package/src/rust/tasks/eject.rs +4 -1
  165. package/src/rust/tasks/pipeline.rs +2 -2
  166. package/src/rust/tasks/presize_panels.rs +44 -5
  167. package/src/rust/tasks/reset_all.rs +4 -4
  168. package/src/rust/tasks/restore_and_render.rs +17 -44
  169. package/src/rust/tasks/restore_panel.rs +37 -22
  170. package/src/rust/tasks/set_edit_mode.rs +3 -1
  171. package/src/rust/tasks/sync_update_panels.rs +18 -24
  172. package/src/rust/tasks/update_theme.rs +23 -18
  173. package/src/rust/utils/css_vars.rs +26 -0
  174. package/src/rust/utils/mod.rs +2 -0
  175. package/src/rust/utils/spawn.rs +1 -1
  176. package/src/rust/workspace.rs +454 -199
  177. package/src/svg/lock.svg +7 -0
  178. package/src/themes/botanical.css +10 -0
  179. package/src/themes/defaults.css +12 -0
  180. package/src/themes/dracula.css +17 -0
  181. package/src/themes/gruvbox-dark.css +20 -0
  182. package/src/themes/gruvbox.css +10 -0
  183. package/src/themes/icons.css +1 -0
  184. package/src/themes/intl/de.css +16 -0
  185. package/src/themes/intl/es.css +16 -0
  186. package/src/themes/intl/fr.css +16 -0
  187. package/src/themes/intl/ja.css +16 -0
  188. package/src/themes/intl/pt.css +16 -0
  189. package/src/themes/intl/zh.css +16 -0
  190. package/src/themes/intl.css +16 -0
  191. package/src/themes/monokai.css +22 -0
  192. package/src/themes/phosphor.css +5 -0
  193. package/src/themes/pro-dark.css +10 -0
  194. package/src/themes/solarized-dark.css +7 -0
  195. package/src/themes/solarized.css +7 -0
  196. package/src/themes/vaporwave.css +20 -0
  197. package/src/ts/column-format.ts +67 -46
  198. package/src/ts/perspective-viewer.ts +3 -0
  199. package/src/ts/plugin.ts +14 -0
  200. package/src/ts/providers.ts +96 -0
  201. package/src/ts/ts-rs/CompactDisplay.ts +3 -0
  202. package/src/ts/ts-rs/CurrencyCode.ts +3 -0
  203. package/src/ts/ts-rs/CurrencyDisplay.ts +3 -0
  204. package/src/ts/ts-rs/CurrencyNumberFormatStyle.ts +6 -0
  205. package/src/ts/ts-rs/CurrencySign.ts +3 -0
  206. package/src/ts/ts-rs/CustomDatetimeFormat.ts +3 -0
  207. package/src/ts/ts-rs/CustomDatetimeStyleConfig.ts +54 -0
  208. package/src/ts/ts-rs/CustomNumberFormatConfig.ts +25 -0
  209. package/src/ts/ts-rs/DatetimeColorMode.ts +3 -0
  210. package/src/ts/ts-rs/DatetimeFormatType.ts +8 -0
  211. package/src/ts/ts-rs/FormatMode.ts +3 -0
  212. package/src/ts/ts-rs/FormatUnit.ts +3 -0
  213. package/src/ts/ts-rs/Notation.ts +11 -0
  214. package/src/ts/ts-rs/NumberFormatStyle.ts +12 -0
  215. package/src/ts/ts-rs/PanelViewerConfig.ts +48 -2
  216. package/src/ts/ts-rs/PluginStaticConfig.ts +36 -1
  217. package/src/ts/ts-rs/RestoreOptions.ts +20 -0
  218. package/src/ts/ts-rs/RoundingMode.ts +3 -0
  219. package/src/ts/ts-rs/RoundingPriority.ts +3 -0
  220. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  221. package/src/ts/ts-rs/SignDisplay.ts +3 -0
  222. package/src/ts/ts-rs/SimpleDatetimeFormat.ts +3 -0
  223. package/src/ts/ts-rs/SimpleDatetimeStyleConfig.ts +25 -0
  224. package/src/ts/ts-rs/SplitRollupMode.ts +11 -0
  225. package/src/ts/ts-rs/StringColorMode.ts +3 -0
  226. package/src/ts/ts-rs/TrailingZeroDisplay.ts +3 -0
  227. package/src/ts/ts-rs/Unit.ts +3 -0
  228. package/src/ts/ts-rs/UnitDisplay.ts +3 -0
  229. package/src/ts/ts-rs/UnitNumberFormatStyle.ts +5 -0
  230. package/src/ts/ts-rs/UseGrouping.ts +3 -0
  231. package/src/ts/ts-rs/ViewerConfig.ts +48 -2
  232. package/src/ts/ts-rs/ViewerConfigInitial.ts +164 -0
  233. package/src/ts/ts-rs/ViewerConfigUpdate.ts +72 -2
  234. package/src/ts/ts-rs/WindowSpec.ts +3 -2
  235. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  236. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +18 -9
  237. package/dist/esm/ts-rs/WindowAggregate.d.ts +0 -1
  238. package/src/rust/components/form/color_range_selector.rs +0 -137
  239. package/src/ts/ts-rs/WindowAggregate.ts +0 -3
  240. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-39c3c3b28e3cbd48}/inline0.js +0 -0
  241. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-39c3c3b28e3cbd48}/inline1.js +0 -0
  242. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-39c3c3b28e3cbd48}/inline2.js +0 -0
  243. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-39c3c3b28e3cbd48}/inline3.js +0 -0
  244. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-39c3c3b28e3cbd48}/inline4.js +0 -0
  245. /package/dist/wasm/snippets/{perspective-viewer-8a8c7d5d696b43ae → perspective-viewer-39c3c3b28e3cbd48}/inline5.js +0 -0
@@ -12,8 +12,7 @@
12
12
 
13
13
  //! The multi-panel model backing a single `<perspective-viewer>`.
14
14
 
15
- use std::cell::RefCell;
16
- use std::collections::HashSet;
15
+ use std::cell::{Cell, RefCell};
17
16
  use std::rc::Rc;
18
17
 
19
18
  use perspective_client::Client;
@@ -21,7 +20,7 @@ use perspective_client::config::Filter;
21
20
 
22
21
  use crate::renderer::Renderer;
23
22
  use crate::session::Session;
24
- use crate::utils::{EffectLedger, PubSub, Subscription};
23
+ use crate::utils::{EffectLedger, PubSub, Subscription, spawn_owned};
25
24
 
26
25
  /// A unique identifier for a [`Panel`] within a [`Workspace`].
27
26
  #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -51,12 +50,7 @@ impl From<&str> for PanelId {
51
50
  }
52
51
  }
53
52
 
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.
53
+ /// The element-level global filter state (master/detail cross-filter.
60
54
  #[derive(Default)]
61
55
  struct GlobalFilterSet {
62
56
  restored: Vec<Filter>,
@@ -91,10 +85,7 @@ impl GlobalFilterSet {
91
85
  before != self.flatten()
92
86
  }
93
87
 
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).
88
+ /// Replace `id`'s contribution.
98
89
  fn set_contribution(&mut self, id: &PanelId, filters: Vec<Filter>) -> bool {
99
90
  self.with_change(|s| {
100
91
  if filters.is_empty() {
@@ -109,11 +100,7 @@ impl GlobalFilterSet {
109
100
  })
110
101
  }
111
102
 
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.
103
+ /// Remove the flattened-view clause at `index` from EVERY bucket.
117
104
  fn remove_clause(&mut self, index: usize) -> (bool, Vec<PanelId>) {
118
105
  let Some(clause) = self.flatten().get(index).cloned() else {
119
106
  return (false, Vec::new());
@@ -155,7 +142,7 @@ impl GlobalFilterSet {
155
142
  (changed, owners)
156
143
  }
157
144
 
158
- /// Whole-element `restore`: replace everything with an unattributed set.
145
+ /// `restoreWorkspace`: replace everything with an unattributed set.
159
146
  fn set_restored(&mut self, filters: Vec<Filter>) -> bool {
160
147
  self.with_change(|s| {
161
148
  s.contributions.clear();
@@ -164,19 +151,12 @@ impl GlobalFilterSet {
164
151
  }
165
152
  }
166
153
 
167
- /// A single, fully-independent viewer-like unit within a [`Workspace`]: its own
168
- /// [`Session`] (table binding + view config) and [`Renderer`] (active plugin).
154
+ /// A single, fully-independent viewer-like unit within a [`Workspace`].
169
155
  #[derive(Clone)]
170
156
  pub struct Panel {
171
157
  pub id: PanelId,
172
158
  pub session: Session,
173
159
  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
160
  _subs: Rc<Vec<Subscription>>,
181
161
  }
182
162
 
@@ -191,6 +171,120 @@ impl Panel {
191
171
  }
192
172
  }
193
173
 
174
+ /// A placed panel's layout phase.
175
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
176
+ pub enum PanelPhase {
177
+ Staging,
178
+ Placed,
179
+ }
180
+
181
+ /// A [`Panel`] as registered in the placed set.
182
+ struct PanelEntry {
183
+ panel: Panel,
184
+ phase: PanelPhase,
185
+ master: bool,
186
+ }
187
+
188
+ #[derive(Default)]
189
+ enum Reservation {
190
+ #[default]
191
+ Idle,
192
+ Pending(Panel),
193
+ Claimed {
194
+ has_table: bool,
195
+ },
196
+ }
197
+
198
+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
199
+ enum FlushState {
200
+ #[default]
201
+ Idle,
202
+ Queued {
203
+ layout: bool,
204
+ active: bool,
205
+ },
206
+ }
207
+
208
+ impl FlushState {
209
+ /// Merge newly-dirty events, returning the next state and whether a flush
210
+ /// task must be spawned.
211
+ fn mark(self, layout: bool, active: bool) -> (Self, bool) {
212
+ match self {
213
+ _ if !layout && !active => (self, false),
214
+ Self::Idle => (Self::Queued { layout, active }, true),
215
+ Self::Queued {
216
+ layout: l,
217
+ active: a,
218
+ } => (
219
+ Self::Queued {
220
+ layout: l || layout,
221
+ active: a || active,
222
+ },
223
+ false,
224
+ ),
225
+ }
226
+ }
227
+ }
228
+
229
+ #[derive(Default)]
230
+ struct LayoutEmitter {
231
+ state: Cell<FlushState>,
232
+ layout_changed: Rc<PubSub<Vec<PanelId>>>,
233
+ active_changed: Rc<PubSub<Option<PanelId>>>,
234
+ }
235
+
236
+ impl LayoutEmitter {
237
+ fn mark(&self, layout: bool, active: bool, workspace: &Workspace) {
238
+ let (next, spawn) = self.state.get().mark(layout, active);
239
+ self.state.set(next);
240
+ if spawn {
241
+ let workspace = workspace.clone();
242
+ spawn_owned("workspace_layout_flush", async move {
243
+ workspace.effects().settle().await;
244
+ workspace.flush_layout_events();
245
+ Ok(())
246
+ });
247
+ }
248
+ }
249
+ }
250
+
251
+ /// The loaded-[`Client`]s registry and the default-client designation. The
252
+ /// invariant "the default is always a registered client" holds in this one
253
+ /// impl: every default assignment registers first, and removal clears both.
254
+ #[derive(Default)]
255
+ struct ClientRegistry {
256
+ clients: Vec<Client>,
257
+ default: Option<Client>,
258
+ client_registered: Rc<PubSub<Client>>,
259
+ }
260
+
261
+ impl ClientRegistry {
262
+ /// Add `client` if a client with the same (globally unique) name isn't
263
+ /// already present, returning — for a genuinely-new client — the pubsub
264
+ /// for the caller to emit OUTSIDE its borrow.
265
+ fn register(&mut self, client: Client) -> Option<Rc<PubSub<Client>>> {
266
+ if self
267
+ .clients
268
+ .iter()
269
+ .any(|c| c.get_name() == client.get_name())
270
+ {
271
+ return None;
272
+ }
273
+
274
+ self.clients.push(client);
275
+ Some(self.client_registered.clone())
276
+ }
277
+
278
+ /// Drop the client named `name`, clearing the default designation if it
279
+ /// referred to it.
280
+ fn remove(&mut self, name: &str) {
281
+ self.clients.retain(|c| c.get_name() != name);
282
+ if self.default.as_ref().is_some_and(|c| c.get_name() == name) {
283
+ self.default = None;
284
+ }
285
+ }
286
+ }
287
+
194
288
  /// The multi-panel model backing a single `<perspective-viewer>`. See the
195
289
  /// module docs for the Phase 1 (single-panel) invariants.
196
290
  #[derive(Clone)]
@@ -203,46 +297,39 @@ impl PartialEq for Workspace {
203
297
  }
204
298
 
205
299
  struct WorkspaceData {
206
- /// Panels in insertion order.
207
- panels: Vec<Panel>,
300
+ /// Panels in insertion order, each with its per-panel attributes
301
+ /// (placement phase, master role).
302
+ panels: Vec<PanelEntry>,
208
303
 
209
304
  /// The currently active/selected panel (a live panel in `panels`).
210
305
  active: Option<PanelId>,
211
306
 
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>,
307
+ /// The element-level global filters.
308
+ filters: GlobalFilterSet,
309
+ filters_changed: Rc<PubSub<()>>,
217
310
 
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>>,
311
+ /// The loaded-clients registry + default designation.
312
+ clients: ClientRegistry,
222
313
 
223
314
  /// Monotonic counter backing [`Workspace::generate_id`].
224
315
  next_id: usize,
225
316
 
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<()>>,
317
+ /// The `load()` reservation slot (see [`Reservation`]).
318
+ reservation: Reservation,
232
319
 
233
- /// A layout tree staged by whole-element `restore`.
320
+ /// A layout tree staged by `restoreWorkspace`.
234
321
  pending_layout: Option<crate::js::Layout>,
235
322
 
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
323
  /// In-flight effects (public mutators + scheduled internal flows),
244
- /// drained by `flush()` — see [`EffectLedger`].
324
+ /// drained by `flush()`.
245
325
  effects: EffectLedger,
326
+
327
+ /// Fires on every [`PanelPhase`] transition.
328
+ staged_changed: Rc<PubSub<()>>,
329
+
330
+ /// Coalesced `layout_changed`/`active_changed` delivery (see
331
+ /// [`LayoutEmitter`]).
332
+ emitter: LayoutEmitter,
246
333
  }
247
334
 
248
335
  impl Default for Workspace {
@@ -257,18 +344,15 @@ impl Workspace {
257
344
  Self(Rc::new(RefCell::new(WorkspaceData {
258
345
  panels: Vec::new(),
259
346
  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
347
  filters: GlobalFilterSet::default(),
266
348
  filters_changed: Rc::new(PubSub::default()),
349
+ clients: ClientRegistry::default(),
350
+ next_id: 0,
351
+ reservation: Reservation::Idle,
267
352
  pending_layout: None,
268
- staged: HashSet::new(),
269
- staged_changed: Rc::new(PubSub::default()),
270
- reserved: None,
271
353
  effects: EffectLedger::default(),
354
+ staged_changed: Rc::new(PubSub::default()),
355
+ emitter: LayoutEmitter::default(),
272
356
  })))
273
357
  }
274
358
 
@@ -277,8 +361,44 @@ impl Workspace {
277
361
  self.0.borrow().effects.clone()
278
362
  }
279
363
 
364
+ pub fn layout_changed(&self) -> Rc<PubSub<Vec<PanelId>>> {
365
+ self.0.borrow().emitter.layout_changed.clone()
366
+ }
367
+
368
+ pub fn active_changed(&self) -> Rc<PubSub<Option<PanelId>>> {
369
+ self.0.borrow().emitter.active_changed.clone()
370
+ }
371
+
372
+ /// Deliver whatever the queued flush recorded, outside any borrow — the
373
+ /// body of the task [`LayoutEmitter::mark`] spawns.
374
+ fn flush_layout_events(&self) {
375
+ let (state, panels, active_id, layout_pubsub, active_pubsub) = {
376
+ let data = self.0.borrow();
377
+ (
378
+ data.emitter.state.replace(FlushState::Idle),
379
+ data.panels
380
+ .iter()
381
+ .map(|p| p.panel.id.clone())
382
+ .collect::<Vec<_>>(),
383
+ data.active.clone(),
384
+ data.emitter.layout_changed.clone(),
385
+ data.emitter.active_changed.clone(),
386
+ )
387
+ };
388
+
389
+ if let FlushState::Queued { layout, active } = state {
390
+ if layout {
391
+ layout_pubsub.emit(panels);
392
+ }
393
+
394
+ if active {
395
+ active_pubsub.emit(active_id);
396
+ }
397
+ }
398
+ }
399
+
280
400
  /// Stage a layout tree for `MainPanel` to apply at its next `rendered`
281
- /// pass (see `WorkspaceData::pending_layout`).
401
+ /// pass (see [`WorkspaceData::pending_layout`]).
282
402
  pub fn set_pending_layout(&self, layout: crate::js::Layout) {
283
403
  self.0.borrow_mut().pending_layout = Some(layout);
284
404
  }
@@ -294,38 +414,42 @@ impl Workspace {
294
414
  self.0.borrow().staged_changed.clone()
295
415
  }
296
416
 
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
417
  /// Promote a staged panel toward layout insertion, returning whether it
310
418
  /// was still staged — restore completion and the staging deadline RACE
311
419
  /// 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) = {
420
+ /// outside the borrow — iff the phase changed.
421
+ pub fn promote(&self, id: &PanelId) -> bool {
422
+ let (promoted, pubsub) = {
315
423
  let mut data = self.0.borrow_mut();
316
- (data.staged.remove(id), data.staged_changed.clone())
424
+ let promoted = data
425
+ .panels
426
+ .iter_mut()
427
+ .find(|p| &p.panel.id == id)
428
+ .map(|entry| {
429
+ matches!(
430
+ std::mem::replace(&mut entry.phase, PanelPhase::Placed),
431
+ PanelPhase::Staging
432
+ )
433
+ })
434
+ .unwrap_or(false);
435
+
436
+ (promoted, data.staged_changed.clone())
317
437
  };
318
438
 
319
- if removed {
439
+ if promoted {
320
440
  pubsub.emit(());
321
441
  }
322
442
 
323
- removed
443
+ promoted
324
444
  }
325
445
 
326
446
  /// Whether `id` is a staged (created, not yet layout-inserted) panel.
327
447
  pub fn is_staged(&self, id: &PanelId) -> bool {
328
- self.0.borrow().staged.contains(id)
448
+ self.0
449
+ .borrow()
450
+ .panels
451
+ .iter()
452
+ .any(|p| &p.panel.id == id && p.phase == PanelPhase::Staging)
329
453
  }
330
454
 
331
455
  /// The EFFECTIVE element-level global filters (master/detail
@@ -358,10 +482,7 @@ impl Workspace {
358
482
  payload
359
483
  }
360
484
 
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`.
485
+ /// Replace the global filter set.
365
486
  pub fn set_global_filters(&self, filters: Vec<Filter>) {
366
487
  self.mutate_filters(|s| (s.set_restored(filters), ()));
367
488
  }
@@ -377,52 +498,61 @@ impl Workspace {
377
498
  self.mutate_filters(|s| (s.set_contribution(id, Vec::new()), ()));
378
499
  }
379
500
 
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`).
501
+ /// Remove the effective-set clause at `index`.
384
502
  pub fn remove_global_filter(&self, index: usize) -> Vec<PanelId> {
385
503
  self.mutate_filters(|s| s.remove_clause(index))
386
504
  }
387
505
 
388
506
  /// 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.
507
+ /// element `reset()`).
392
508
  pub fn clear_global_filters(&self) -> Vec<PanelId> {
393
509
  self.mutate_filters(GlobalFilterSet::clear)
394
510
  }
395
511
 
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`).
512
+ /// The master (filter-source) panel ids.
400
513
  pub fn masters(&self) -> Vec<PanelId> {
401
- let mut masters: Vec<_> = self.0.borrow().masters.iter().cloned().collect();
514
+ let mut masters: Vec<_> = self
515
+ .0
516
+ .borrow()
517
+ .panels
518
+ .iter()
519
+ .filter(|p| p.master)
520
+ .map(|p| p.panel.id.clone())
521
+ .collect();
522
+
402
523
  masters.sort();
403
524
  masters
404
525
  }
405
526
 
406
- /// Replace the master role set — the whole-element `restore` entry point
407
- /// (ids already remapped to the fresh panel ids).
527
+ /// Replace the master role set — the `restoreWorkspace` entry point
528
+ /// (ids already remapped to the fresh panel ids). Ids naming no panel are
529
+ /// dropped (the caller has already warned on them).
408
530
  pub fn set_masters(&self, ids: Vec<PanelId>) {
409
- self.0.borrow_mut().masters = ids.into_iter().collect();
531
+ let mut data = self.0.borrow_mut();
532
+ for entry in data.panels.iter_mut() {
533
+ entry.master = ids.contains(&entry.panel.id);
534
+ }
410
535
  }
411
536
 
412
537
  /// Whether `id` is a master (filter-source) panel.
413
538
  pub fn is_master(&self, id: &PanelId) -> bool {
414
- self.0.borrow().masters.contains(id)
539
+ self.0
540
+ .borrow()
541
+ .panels
542
+ .iter()
543
+ .any(|p| &p.panel.id == id && p.master)
415
544
  }
416
545
 
417
546
  /// Toggle `id`'s master/detail role, returning the new state (`true` =
418
- /// master).
547
+ /// master). `false` (no role) if `id` names no panel.
419
548
  pub fn toggle_master(&self, id: &PanelId) -> bool {
420
549
  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
550
+ match data.panels.iter_mut().find(|p| &p.panel.id == id) {
551
+ Some(entry) => {
552
+ entry.master = !entry.master;
553
+ entry.master
554
+ },
555
+ None => false,
426
556
  }
427
557
  }
428
558
 
@@ -444,7 +574,10 @@ impl Workspace {
444
574
  pub fn active_panel(&self) -> Option<Panel> {
445
575
  let data = self.0.borrow();
446
576
  let active = data.active.as_ref()?;
447
- data.panels.iter().find(|p| &p.id == active).cloned()
577
+ data.panels
578
+ .iter()
579
+ .find(|p| &p.panel.id == active)
580
+ .map(|p| p.panel.clone())
448
581
  }
449
582
 
450
583
  /// The active panel's [`Session`], or `None` with zero panels.
@@ -459,7 +592,12 @@ impl Workspace {
459
592
 
460
593
  /// Look up a [`Panel`] by id.
461
594
  pub fn panel(&self, id: &PanelId) -> Option<Panel> {
462
- self.0.borrow().panels.iter().find(|p| &p.id == id).cloned()
595
+ self.0
596
+ .borrow()
597
+ .panels
598
+ .iter()
599
+ .find(|p| &p.panel.id == id)
600
+ .map(|p| p.panel.clone())
463
601
  }
464
602
 
465
603
  /// Resolve a panel by id, or the active panel when `id` is `None` — the
@@ -475,17 +613,23 @@ impl Workspace {
475
613
  /// fan-out source (fan-outs collect per-panel results; they never
476
614
  /// sequential-abort on one panel's error).
477
615
  pub fn panels(&self) -> Vec<Panel> {
478
- self.0.borrow().panels.to_vec()
616
+ self.0
617
+ .borrow()
618
+ .panels
619
+ .iter()
620
+ .map(|p| p.panel.clone())
621
+ .collect()
479
622
  }
480
623
 
481
624
  /// The number of PLACED panels (panels minus staged) — the single
482
625
  /// source for panel-count chrome (`single`/`multi`, closable,
483
626
  /// draggable, `only-child`).
484
627
  pub fn placed_count(&self) -> usize {
485
- let data = self.0.borrow();
486
- data.panels
628
+ self.0
629
+ .borrow()
630
+ .panels
487
631
  .iter()
488
- .filter(|p| !data.staged.contains(&p.id))
632
+ .filter(|p| p.phase == PanelPhase::Placed)
489
633
  .count()
490
634
  }
491
635
 
@@ -495,7 +639,7 @@ impl Workspace {
495
639
  .borrow()
496
640
  .panels
497
641
  .iter()
498
- .map(|p| p.id.clone())
642
+ .map(|p| p.panel.id.clone())
499
643
  .collect()
500
644
  }
501
645
 
@@ -509,50 +653,119 @@ impl Workspace {
509
653
  self.0.borrow().panels.is_empty()
510
654
  }
511
655
 
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
- }
656
+ /// Append a [`Panel`] at `phase`. When the element had zero panels, the
657
+ /// inserted panel becomes the active one (there is no other candidate).
658
+ /// A [`PanelPhase::Staging`] insert emits `staged_changed` — outside the
659
+ /// borrow — so the phase and its announcement are one operation.
660
+ pub fn insert_panel(&self, panel: Panel, phase: PanelPhase) {
661
+ let staged_pubsub = {
662
+ let mut data = self.0.borrow_mut();
663
+ let activated = if data.active.is_none() {
664
+ data.active = Some(panel.id.clone());
665
+ true
666
+ } else {
667
+ false
668
+ };
669
+
670
+ panel
671
+ .renderer
672
+ .set_active_flag(data.active.as_ref() == Some(&panel.id));
673
+ data.panels.push(PanelEntry {
674
+ panel,
675
+ phase,
676
+ master: false,
677
+ });
519
678
 
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);
679
+ Self::sync_solo_flags(&data);
680
+ data.emitter.mark(true, activated, self);
681
+ (phase == PanelPhase::Staging).then(|| data.staged_changed.clone())
682
+ };
683
+
684
+ if let Some(pubsub) = staged_pubsub {
685
+ pubsub.emit(());
686
+ }
525
687
  }
526
688
 
527
- /// Hold `panel` in the reservation slot (see [`WorkspaceData::reserved`]):
689
+ /// Hold `panel` in the reservation slot ([`Reservation::Pending`]):
528
690
  /// NOT placed, invisible to every placed-panel consumer, awaiting a
529
691
  /// pending `load()`'s payload classification or an interim claimant.
692
+ /// Resets any prior claim record — the slot serves one `load()` cycle at
693
+ /// a time.
530
694
  pub fn reserve_panel(&self, panel: Panel) {
531
- self.0.borrow_mut().reserved = Some(panel);
695
+ let mut data = self.0.borrow_mut();
696
+ debug_assert!(
697
+ !matches!(data.reservation, Reservation::Pending(_)),
698
+ "reservation overwritten while pending — adopt via `reserved_panel` first"
699
+ );
700
+
701
+ data.reservation = Reservation::Pending(panel);
532
702
  }
533
703
 
534
704
  /// The reservation slot's current occupant (shared handles), without a
535
705
  /// transfer — a second `load()` on a still-empty element adopts the same
536
706
  /// reservation rather than creating a competing one.
537
707
  pub fn reserved_panel(&self) -> Option<Panel> {
538
- self.0.borrow().reserved.clone()
708
+ match &self.0.borrow().reservation {
709
+ Reservation::Pending(panel) => Some(panel.clone()),
710
+ _ => None,
711
+ }
539
712
  }
540
713
 
541
- /// PLACE the reserved panel: transfer it out of the reservation slot into
714
+ /// CLAIM the reserved panel: transfer it out of the reservation slot into
542
715
  /// 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());
716
+ /// element), recording `has_table` — whether the claimant carried a
717
+ /// `table` — atomically with the transfer ([`Reservation::Claimed`]).
718
+ /// `None` when nothing is pending (no reservation, or the other actor
719
+ /// transferred first).
720
+ pub fn claim_reserved(&self, has_table: bool) -> Option<Panel> {
721
+ let panel = {
722
+ let mut data = self.0.borrow_mut();
723
+ match std::mem::take(&mut data.reservation) {
724
+ Reservation::Pending(panel) => {
725
+ data.reservation = Reservation::Claimed { has_table };
726
+ Some(panel)
727
+ },
728
+ other => {
729
+ data.reservation = other;
730
+ None
731
+ },
732
+ }
733
+ }?;
734
+
735
+ self.insert_panel(panel.clone(), PanelPhase::Placed);
548
736
  Some(panel)
549
737
  }
550
738
 
551
739
  /// DISCARD the reserved panel: transfer it out of the reservation slot
552
740
  /// WITHOUT placing it, for disposal (an inert `Client` payload with no
553
- /// claimant, or teardown draining). `None` when the slot is empty.
741
+ /// claimant, or teardown draining). `None` when nothing is pending; a
742
+ /// claim record is left intact for [`Self::resolve_claim`].
554
743
  pub fn take_reserved(&self) -> Option<Panel> {
555
- self.0.borrow_mut().reserved.take()
744
+ let mut data = self.0.borrow_mut();
745
+ match std::mem::take(&mut data.reservation) {
746
+ Reservation::Pending(panel) => Some(panel),
747
+ other => {
748
+ data.reservation = other;
749
+ None
750
+ },
751
+ }
752
+ }
753
+
754
+ /// ONE-SHOT read of the claim record: `Some(has_table)` if the
755
+ /// reservation was claimed ([`Reservation::Claimed`]) — resetting the
756
+ /// slot to [`Reservation::Idle`] — or `None` if no claim happened.
757
+ /// Consuming closes the stale-flag window the former boolean had —
758
+ /// `load()`'s inert-`Client` epilogue is the only reader, and reads it
759
+ /// exactly once (eviction arms on `Some(false)`).
760
+ pub fn resolve_claim(&self) -> Option<bool> {
761
+ let mut data = self.0.borrow_mut();
762
+ match data.reservation {
763
+ Reservation::Claimed { has_table } => {
764
+ data.reservation = Reservation::Idle;
765
+ Some(has_table)
766
+ },
767
+ _ => None,
768
+ }
556
769
  }
557
770
 
558
771
  /// Sync every panel renderer's solo (lone-panel) flag with the current
@@ -561,47 +774,49 @@ impl Workspace {
561
774
  /// locked plugin dispatch — see `Renderer::stamp_active`).
562
775
  fn sync_solo_flags(data: &WorkspaceData) {
563
776
  let is_solo = data.panels.len() == 1;
564
- for panel in data.panels.iter() {
565
- panel.renderer.set_solo_flag(is_solo);
777
+ for entry in data.panels.iter() {
778
+ entry.panel.renderer.set_solo_flag(is_solo);
566
779
  }
567
780
  }
568
781
 
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.
782
+ /// Remove a [`Panel`] by id, returning it if present. The entry's
783
+ /// attributes (phase, master role) leave with it structurally; its
784
+ /// global-filter contribution is dropped here, so neither can outlive
785
+ /// the panel on ANY removal path (close, `restoreWorkspace`'s batch
786
+ /// replacement).
574
787
  pub fn remove_panel(&self, id: &PanelId) -> Option<Panel> {
575
- let (removed, changed, pubsub, staged_removed, staged_pubsub) = {
788
+ let (removed, changed, filters_pubsub, was_staged, staged_pubsub) = {
576
789
  let mut data = self.0.borrow_mut();
577
- data.masters.remove(id);
578
- let staged_removed = data.staged.remove(id);
579
790
  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) {
791
+ let (removed, was_staged) = match data.panels.iter().position(|p| &p.panel.id == id) {
792
+ Some(idx) => {
793
+ let entry = data.panels.remove(idx);
794
+ (Some(entry.panel), entry.phase == PanelPhase::Staging)
795
+ },
796
+ None => (None, false),
797
+ };
798
+
799
+ let deactivated = data.active.as_ref() == Some(id);
800
+ if deactivated {
587
801
  data.active = None;
588
802
  }
589
803
 
590
804
  Self::sync_solo_flags(&data);
805
+ data.emitter.mark(removed.is_some(), deactivated, self);
591
806
  (
592
807
  removed,
593
808
  changed,
594
809
  data.filters_changed.clone(),
595
- staged_removed,
810
+ was_staged,
596
811
  data.staged_changed.clone(),
597
812
  )
598
813
  };
599
814
 
600
815
  if changed {
601
- pubsub.emit(());
816
+ filters_pubsub.emit(());
602
817
  }
603
818
 
604
- if staged_removed {
819
+ if was_staged {
605
820
  staged_pubsub.emit(());
606
821
  }
607
822
 
@@ -612,14 +827,17 @@ impl Workspace {
612
827
  /// panel.
613
828
  pub fn set_active(&self, id: PanelId) -> bool {
614
829
  let mut data = self.0.borrow_mut();
615
- if data.panels.iter().any(|p| p.id == id) {
830
+ if data.panels.iter().any(|p| p.panel.id == id) {
831
+ let changed = data.active.as_ref() != Some(&id);
616
832
  data.active = Some(id);
617
- for panel in data.panels.iter() {
618
- panel
833
+ for entry in data.panels.iter() {
834
+ entry
835
+ .panel
619
836
  .renderer
620
- .set_active_flag(data.active.as_ref() == Some(&panel.id));
837
+ .set_active_flag(data.active.as_ref() == Some(&entry.panel.id));
621
838
  }
622
839
 
840
+ data.emitter.mark(false, changed, self);
623
841
  true
624
842
  } else {
625
843
  false
@@ -628,7 +846,7 @@ impl Workspace {
628
846
 
629
847
  /// The default [`Client`], if one has been loaded.
630
848
  pub fn default_client(&self) -> Option<Client> {
631
- self.0.borrow().default_client.clone()
849
+ self.0.borrow().clients.default.clone()
632
850
  }
633
851
 
634
852
  /// The active panel's bound [`Client`], if any — the default target of a
@@ -644,8 +862,13 @@ impl Workspace {
644
862
  .borrow()
645
863
  .panels
646
864
  .iter()
647
- .filter(|p| p.session.get_client().is_some_and(|c| c.get_name() == name))
648
- .map(|p| p.id.clone())
865
+ .filter(|p| {
866
+ p.panel
867
+ .session
868
+ .get_client()
869
+ .is_some_and(|c| c.get_name() == name)
870
+ })
871
+ .map(|p| p.panel.id.clone())
649
872
  .collect()
650
873
  }
651
874
 
@@ -655,26 +878,27 @@ impl Workspace {
655
878
  /// [`Workspace::panels_for_client`]) — `clients()` unions in live panel
656
879
  /// sessions, so a lingering panel would resurrect it.
657
880
  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
- }
881
+ self.0.borrow_mut().clients.remove(name);
667
882
  }
668
883
 
669
884
  /// Record the default [`Client`] if not already set (first-wins, matching
670
885
  /// the "first `Client` passed to `load()` is the default" rule). Always
671
886
  /// registers the client (see [`Workspace::register_client`]) — first-wins
672
- /// applies only to the *default* designation.
887
+ /// applies only to the *default* designation, which is assigned BEFORE
888
+ /// the registration event fires, so listeners observe both together.
673
889
  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);
890
+ let pubsub = {
891
+ let mut data = self.0.borrow_mut();
892
+ let pubsub = data.clients.register(client.clone());
893
+ if data.clients.default.is_none() {
894
+ data.clients.default = Some(client.clone());
895
+ }
896
+
897
+ pubsub
898
+ };
899
+
900
+ if let Some(pubsub) = pubsub {
901
+ pubsub.emit(client);
678
902
  }
679
903
  }
680
904
 
@@ -682,27 +906,15 @@ impl Workspace {
682
906
  /// same (globally unique) name isn't already present. Emits
683
907
  /// `client_registered` — outside the borrow — for a genuinely-new client.
684
908
  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);
909
+ let pubsub = self.0.borrow_mut().clients.register(client.clone());
910
+ if let Some(pubsub) = pubsub {
911
+ pubsub.emit(client);
912
+ }
700
913
  }
701
914
 
702
- /// A handle to the `client_registered` PubSub (see
703
- /// [`WorkspaceData::client_registered`]).
915
+ /// A handle to the `client_registered` PubSub (see [`ClientRegistry`]).
704
916
  pub fn client_registered(&self) -> Rc<PubSub<Client>> {
705
- self.0.borrow().client_registered.clone()
917
+ self.0.borrow().clients.client_registered.clone()
706
918
  }
707
919
 
708
920
  /// All loaded [`Client`]s: the registry, unioned with every panel
@@ -710,9 +922,9 @@ impl Workspace {
710
922
  /// bypasses registration), deduped by name in registration order.
711
923
  pub fn clients(&self) -> Vec<Client> {
712
924
  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()
925
+ let mut clients = data.clients.clients.clone();
926
+ for entry in &data.panels {
927
+ if let Some(client) = entry.panel.session.get_client()
716
928
  && !clients.iter().any(|c| c.get_name() == client.get_name())
717
929
  {
718
930
  clients.push(client);
@@ -843,4 +1055,47 @@ mod tests {
843
1055
  assert_eq!(s.flatten(), Vec::<Filter>::new());
844
1056
  assert_eq!(s.clear(), (false, Vec::new()));
845
1057
  }
1058
+
1059
+ #[test]
1060
+ fn flush_state_spawns_exactly_once_per_cycle() {
1061
+ let (state, spawn) = FlushState::Idle.mark(true, false);
1062
+ assert!(spawn);
1063
+ assert_eq!(state, FlushState::Queued {
1064
+ layout: true,
1065
+ active: false
1066
+ });
1067
+
1068
+ // Subsequent marks merge without re-spawning.
1069
+ let (state, spawn) = state.mark(false, true);
1070
+ assert!(!spawn);
1071
+ assert_eq!(state, FlushState::Queued {
1072
+ layout: true,
1073
+ active: true
1074
+ });
1075
+
1076
+ let (state, spawn) = state.mark(true, true);
1077
+ assert!(!spawn);
1078
+ assert_eq!(state, FlushState::Queued {
1079
+ layout: true,
1080
+ active: true
1081
+ });
1082
+ }
1083
+
1084
+ #[test]
1085
+ fn flush_state_ignores_empty_marks() {
1086
+ // A no-event mark neither queues nor spawns — `Queued{false,false}`
1087
+ // is unconstructible through `mark`.
1088
+ let (state, spawn) = FlushState::Idle.mark(false, false);
1089
+ assert!(!spawn);
1090
+ assert_eq!(state, FlushState::Idle);
1091
+
1092
+ let queued = FlushState::Queued {
1093
+ layout: true,
1094
+ active: false,
1095
+ };
1096
+
1097
+ let (state, spawn) = queued.mark(false, false);
1098
+ assert!(!spawn);
1099
+ assert_eq!(state, queued);
1100
+ }
846
1101
  }