@perspective-dev/viewer 5.4.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/cdn/perspective-viewer.js +4 -4
  2. package/dist/cdn/perspective-viewer.js.map +2 -2
  3. package/dist/css/blueprint.css +1 -1
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -1
  7. package/dist/css/gruvbox-dark.css +1 -1
  8. package/dist/css/gruvbox.css +1 -1
  9. package/dist/css/icons.css +1 -1
  10. package/dist/css/intl/ar.css +1 -1
  11. package/dist/css/intl/de.css +1 -1
  12. package/dist/css/intl/es.css +1 -1
  13. package/dist/css/intl/fr.css +1 -1
  14. package/dist/css/intl/ja.css +1 -1
  15. package/dist/css/intl/pt.css +1 -1
  16. package/dist/css/intl/zh.css +1 -1
  17. package/dist/css/intl.css +1 -1
  18. package/dist/css/ledger.css +1 -1
  19. package/dist/css/monokai.css +1 -1
  20. package/dist/css/nord.css +1 -1
  21. package/dist/css/phosphor.css +1 -1
  22. package/dist/css/pro-dark.css +1 -1
  23. package/dist/css/pro.css +1 -1
  24. package/dist/css/solarized-dark.css +1 -1
  25. package/dist/css/solarized.css +1 -1
  26. package/dist/css/themes.css +1 -1
  27. package/dist/css/vaporwave.css +1 -1
  28. package/dist/css/velvet.css +1 -1
  29. package/dist/docs/perspective-docs.json +1 -1
  30. package/dist/esm/perspective-viewer.d.ts +2 -0
  31. package/dist/esm/perspective-viewer.inline.js +9 -9
  32. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  33. package/dist/esm/perspective-viewer.js +4 -4
  34. package/dist/esm/perspective-viewer.js.map +2 -2
  35. package/dist/esm/plugin.d.ts +9 -0
  36. package/dist/esm/ts-rs/AddPanelOptions.d.ts +9 -0
  37. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -1
  38. package/dist/esm/ts-rs/GetViewMode.d.ts +4 -0
  39. package/dist/esm/ts-rs/GetViewOptions.d.ts +8 -0
  40. package/dist/esm/ts-rs/RestoreOptions.d.ts +6 -0
  41. package/dist/esm/ts-rs/RestoreWorkspaceOptions.d.ts +9 -0
  42. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +30 -22
  43. package/dist/wasm/perspective-viewer.d.ts +102 -47
  44. package/dist/wasm/perspective-viewer.js +233 -142
  45. package/dist/wasm/perspective-viewer.wasm +0 -0
  46. package/dist/wasm/perspective-viewer.wasm.d.ts +18 -18
  47. package/package.json +7 -7
  48. package/src/css/column-selector.css +11 -3
  49. package/src/css/column-style.css +162 -0
  50. package/src/css/config-selector.css +1 -1
  51. package/src/css/form/debug.css +9 -0
  52. package/src/css/status-bar.css +11 -0
  53. package/src/css/ui/containers/tabs.css +2 -1
  54. package/src/rust/agent/tools.rs +80 -7
  55. package/src/rust/components/column_selector/active_column.rs +9 -0
  56. package/src/rust/components/column_selector.rs +11 -0
  57. package/src/rust/components/column_settings_sidebar/style_tab/alignment_field.rs +90 -0
  58. package/src/rust/components/column_settings_sidebar/style_tab/font_field.rs +267 -0
  59. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +1 -1
  60. package/src/rust/components/column_settings_sidebar/style_tab.rs +68 -9
  61. package/src/rust/components/debug_panel.rs +107 -50
  62. package/src/rust/components/export_dropdown.rs +6 -0
  63. package/src/rust/components/main_panel/msg.rs +4 -0
  64. package/src/rust/components/main_panel.rs +14 -0
  65. package/src/rust/components/mod.rs +0 -1
  66. package/src/rust/components/plugin_tab.rs +57 -3
  67. package/src/rust/components/settings_panel.rs +6 -0
  68. package/src/rust/components/status_bar.rs +12 -3
  69. package/src/rust/components/status_indicator.rs +4 -0
  70. package/src/rust/components/viewer/filters.rs +2 -2
  71. package/src/rust/components/viewer/msg.rs +4 -0
  72. package/src/rust/components/viewer/panels.rs +6 -3
  73. package/src/rust/components/viewer/render.rs +1 -0
  74. package/src/rust/components/viewer/wiring.rs +14 -2
  75. package/src/rust/components/viewer.rs +2 -2
  76. package/src/rust/config/column_config_schema.rs +312 -8
  77. package/src/rust/config/datetime_column_style.rs +0 -11
  78. package/src/rust/config/export_method.rs +24 -0
  79. package/src/rust/config/mod.rs +0 -2
  80. package/src/rust/config/options.rs +50 -0
  81. package/src/rust/config/workspace_config.rs +69 -24
  82. package/src/rust/custom_elements/viewer.rs +214 -113
  83. package/src/rust/js/plugin.rs +3 -1
  84. package/src/rust/lib.rs +7 -3
  85. package/src/rust/queries/plugin_column_styles.rs +6 -5
  86. package/src/rust/renderer/plugin_config.rs +200 -41
  87. package/src/rust/renderer/plugin_store.rs +2 -1
  88. package/src/rust/renderer/props.rs +5 -0
  89. package/src/rust/renderer.rs +9 -0
  90. package/src/rust/session/metadata.rs +5 -10
  91. package/src/rust/session/props.rs +4 -0
  92. package/src/rust/session.rs +273 -195
  93. package/src/rust/tasks/copy_export.rs +67 -0
  94. package/src/rust/tasks/create_panel.rs +16 -13
  95. package/src/rust/tasks/pipeline.rs +26 -8
  96. package/src/rust/tasks/restore_panel.rs +129 -30
  97. package/src/rust/tasks/send_column_config.rs +3 -0
  98. package/src/rust/tasks/sync_update_panels.rs +148 -68
  99. package/src/rust/tasks/table_lifecycle.rs +7 -1
  100. package/src/rust/ui/containers/tab_list.rs +1 -1
  101. package/src/rust/ui/form/intl_label.rs +4 -23
  102. package/src/rust/ui/portal.rs +1 -0
  103. package/src/rust/utils/css_vars.rs +13 -0
  104. package/src/rust/{config/datetime_column_style/color_mode.rs → utils/in_flight.rs} +47 -35
  105. package/src/rust/utils/local_fonts.rs +395 -0
  106. package/src/rust/utils/mod.rs +4 -0
  107. package/src/rust/utils/pending_effects.rs +6 -30
  108. package/src/rust/workspace.rs +12 -0
  109. package/src/svg/status_pending.svg +3 -0
  110. package/src/themes/icons.css +1 -0
  111. package/src/themes/intl/ar.css +20 -7
  112. package/src/themes/intl/de.css +20 -7
  113. package/src/themes/intl/es.css +20 -7
  114. package/src/themes/intl/fr.css +20 -7
  115. package/src/themes/intl/ja.css +20 -7
  116. package/src/themes/intl/pt.css +20 -7
  117. package/src/themes/intl/zh.css +20 -7
  118. package/src/themes/intl.css +20 -7
  119. package/src/themes/monokai.css +1 -1
  120. package/src/themes/vaporwave.css +1 -1
  121. package/src/ts/perspective-viewer.ts +2 -0
  122. package/src/ts/plugin.ts +9 -0
  123. package/src/ts/ts-rs/AddPanelOptions.ts +10 -0
  124. package/src/ts/ts-rs/ExportMethod.ts +1 -1
  125. package/src/ts/ts-rs/{DatetimeColorMode.ts → GetViewMode.ts} +4 -1
  126. package/src/ts/ts-rs/GetViewOptions.ts +7 -0
  127. package/src/ts/ts-rs/RestoreOptions.ts +7 -1
  128. package/src/ts/ts-rs/RestoreWorkspaceOptions.ts +10 -0
  129. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +31 -20
  130. package/dist/esm/ts-rs/DatetimeColorMode.d.ts +0 -1
  131. package/dist/esm/ts-rs/FormatMode.d.ts +0 -1
  132. package/dist/esm/ts-rs/StringColorMode.d.ts +0 -1
  133. package/src/rust/components/string_column_style.rs +0 -120
  134. package/src/rust/config/string_column_style.rs +0 -135
  135. package/src/ts/ts-rs/FormatMode.ts +0 -3
  136. package/src/ts/ts-rs/StringColorMode.ts +0 -3
  137. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline0.js +0 -0
  138. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline1.js +0 -0
  139. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline2.js +0 -0
  140. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline3.js +0 -0
  141. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline4.js +0 -0
  142. /package/dist/wasm/snippets/{perspective-viewer-adc6f3f79141112b → perspective-viewer-6813b568219c6763}/inline5.js +0 -0
@@ -17,7 +17,7 @@ use wasm_bindgen::prelude::*;
17
17
  use crate::components::viewer::PerspectiveViewerMsg;
18
18
  use crate::config::*;
19
19
  use crate::renderer::*;
20
- use crate::session::Session;
20
+ use crate::session::{Disposal, Session};
21
21
  use crate::tasks::*;
22
22
  use crate::workspace::PanelId;
23
23
  use crate::*;
@@ -26,6 +26,9 @@ use crate::*;
26
26
  extern "C" {
27
27
  #[wasm_bindgen(typescript_type = "ViewerConfigUpdate")]
28
28
  pub type JsViewerConfigUpdate;
29
+
30
+ #[wasm_bindgen(typescript_type = "WorkspaceConfigUpdate")]
31
+ pub type JsWorkspaceConfigUpdate;
29
32
  }
30
33
 
31
34
  pub type SyncUpdatePanelsResult = (
@@ -33,12 +36,12 @@ pub type SyncUpdatePanelsResult = (
33
36
  std::vec::Vec<perspective_js::utils::ApiFuture<()>>,
34
37
  );
35
38
 
36
- /// Update the panel state to match a `WorkspaceConfigUpdate`.
37
- ///
38
- /// This method must be synchronous.
39
+ /// Synchronously apply a `WorkspaceConfigUpdate` to the element's models
40
+ /// field-wise, replacing the panel set only when `panels` is present, and
41
+ /// return the fresh panels to restore plus the eject tasks.
39
42
  pub fn sync_update_panels(
40
43
  this: &PerspectiveViewerElement,
41
- update: JsViewerConfigUpdate,
44
+ update: JsWorkspaceConfigUpdate,
42
45
  ) -> ApiResult<SyncUpdatePanelsResult> {
43
46
  let WorkspaceConfigUpdate {
44
47
  active,
@@ -49,98 +52,175 @@ pub fn sync_update_panels(
49
52
  palette,
50
53
  } = update.into_serde_ext()?;
51
54
 
52
- let palette = validate_palette(palette.unwrap_or_default())
53
- .map_err(|e| ApiError::from(JsValue::from_str(&e)))?;
55
+ let palette = match palette {
56
+ OptionalUpdate::Missing => None,
57
+ OptionalUpdate::SetDefault => Some(Default::default()),
58
+ OptionalUpdate::Update(palette) => {
59
+ Some(validate_palette(palette).map_err(|e| ApiError::from(JsValue::from_str(&e)))?)
60
+ },
61
+ };
62
+
54
63
  let old_ids = this.workspace.panel_ids();
64
+ let replace = panels.is_some();
55
65
 
56
66
  // Phase 1 — models only, NO renders and NO draws
57
67
  let mut id_map: HashMap<String, String> = Default::default();
58
- let mut fallback_fresh: Option<String> = None;
59
- let mut active_fresh: Option<String> = None;
60
68
  let mut contents = Vec::new();
61
- // `panels` entries are `ViewerConfigInitial`s — `table` required by
62
- // TYPE (a placed panel without a binding would be permanently blank),
63
- // and no per-panel `settings` exists (element-level; carried by the
64
- // top-level `active` field). An EMPTY `panels` map restores to the
65
- // zero-panel empty stage — the former table-less fallback panel was
66
- // exactly the blank-panel state this type exists to preclude.
67
- for (saved_id, config) in panels {
68
- let (fresh, session, renderer, config) = create_panel_model(
69
- &this.elem,
70
- &this.presentation,
71
- &this.workspace,
72
- None,
73
- config.into(),
74
- None,
75
- Placement::Placed,
76
- );
77
-
78
- if active.as_deref() == Some(saved_id.as_str()) {
79
- active_fresh = Some(fresh.as_str().to_owned());
80
- }
81
-
82
- fallback_fresh.get_or_insert_with(|| fresh.as_str().to_owned());
83
- id_map.insert(saved_id, fresh.as_str().to_owned());
84
- contents.push((fresh, session, renderer, config));
85
- }
86
-
87
69
  let mut eject_tasks = Vec::new();
88
- if let Some(panel) = this.workspace.take_reserved() {
89
- eject_tasks.push(eject_panel(panel));
70
+ match panels {
71
+ // `panels` entries are `ViewerConfigInitial`s — `table` required by
72
+ // TYPE (a placed panel without a binding would be permanently blank),
73
+ // and no per-panel `settings` exists (element-level; carried by the
74
+ // top-level `active` field). An EMPTY `panels` map restores to the
75
+ // zero-panel empty stage — the former table-less fallback panel was
76
+ // exactly the blank-panel state this type exists to preclude.
77
+ Some(panels) => {
78
+ for (saved_id, config) in panels {
79
+ let (fresh, session, renderer, config) = create_panel_model(
80
+ &this.elem,
81
+ &this.presentation,
82
+ &this.workspace,
83
+ None,
84
+ config.into(),
85
+ None,
86
+ Placement::Placed,
87
+ );
88
+
89
+ id_map.insert(saved_id, fresh.as_str().to_owned());
90
+ contents.push((fresh, session, renderer, config));
91
+ }
92
+
93
+ if let Some(panel) = this.workspace.take_reserved() {
94
+ eject_tasks.push(eject_panel(panel, Disposal::Reject));
95
+ }
96
+
97
+ for old in &old_ids {
98
+ if let Some(panel) = this.workspace.remove_panel(old) {
99
+ eject_tasks.push(eject_panel(panel, Disposal::Reject));
100
+ }
101
+ }
102
+ },
103
+ None => {
104
+ for id in &old_ids {
105
+ id_map.insert(id.as_str().to_owned(), id.as_str().to_owned());
106
+ }
107
+ },
90
108
  }
91
109
 
92
- for old in old_ids {
93
- if let Some(panel) = this.workspace.remove_panel(&old) {
94
- eject_tasks.push(eject_panel(panel));
95
- }
110
+ if let Some(palette) = palette {
111
+ this.presentation.set_palette(palette)?;
96
112
  }
97
113
 
98
- this.presentation.set_palette(palette)?;
99
-
100
114
  // Phase 2 — stage the remapped layout tree on the Workspace
115
+ let layout_staged = layout.is_some();
101
116
  if let Some(layout) = layout {
102
117
  this.workspace
103
118
  .set_pending_layout(layout.remap(&|name| id_map.get(name).cloned()));
119
+ if !replace {
120
+ this.workspace.layout_staged().emit(());
121
+ }
104
122
  }
105
123
 
106
124
  // Phase 3 — the single visible commit
107
- if let Some(saved) = &active
108
- && active_fresh.is_none()
109
- {
110
- tracing::warn!("`active` names unknown panel \"{saved}\"");
111
- }
112
-
113
- let sidebar_open = active_fresh.is_some();
114
- if let Some(target) = active_fresh.or(fallback_fresh)
125
+ let active_target = match &active {
126
+ OptionalUpdate::Update(saved) => {
127
+ let target = id_map.get(saved).cloned();
128
+ if target.is_none() {
129
+ tracing::warn!("`active` names unknown panel \"{saved}\"");
130
+ }
131
+
132
+ target
133
+ },
134
+ _ => None,
135
+ };
136
+
137
+ let commit_target = if replace {
138
+ active_target
139
+ .clone()
140
+ .or_else(|| contents.first().map(|(id, ..)| id.as_str().to_owned()))
141
+ } else if layout_staged || active_target.is_some() {
142
+ active_target
143
+ .clone()
144
+ .or_else(|| this.workspace.active_id().map(|id| id.as_str().to_owned()))
145
+ } else {
146
+ None
147
+ };
148
+
149
+ if let Some(target) = commit_target
115
150
  && let Some(app) = this.root.borrow().as_ref()
116
151
  {
117
152
  app.send_message(PerspectiveViewerMsg::CommitWorkspaceRestore(target));
118
153
  }
119
154
 
120
- if let Some(app) = this.root.borrow().as_ref() {
155
+ let settings = if replace {
156
+ Some(active_target.is_some())
157
+ } else {
158
+ match active {
159
+ OptionalUpdate::Update(_) => active_target.as_ref().map(|_| true),
160
+ OptionalUpdate::SetDefault => Some(false),
161
+ OptionalUpdate::Missing => None,
162
+ }
163
+ };
164
+
165
+ if let Some(open) = settings
166
+ && let Some(app) = this.root.borrow().as_ref()
167
+ {
121
168
  // Silent (`announce: false`): each restored panel's own view-config
122
169
  // commit dispatch announces the config, settings field included.
123
170
  app.send_message(PerspectiveViewerMsg::ToggleSettingsInit(
124
- Some(SettingsUpdate::Update(sidebar_open)),
171
+ Some(SettingsUpdate::Update(open)),
125
172
  false,
126
173
  None,
127
174
  ));
128
175
  }
129
176
 
130
- let masters = masters
131
- .unwrap_or_default()
132
- .into_iter()
133
- .filter_map(|saved| match id_map.get(&saved) {
134
- Some(fresh) => Some(PanelId::from(fresh.as_str())),
135
- None => {
136
- tracing::warn!("`masters` names unknown panel \"{saved}\"");
137
- None
138
- },
139
- })
140
- .collect::<Vec<_>>();
141
-
142
- this.workspace.set_masters(masters);
143
- this.workspace
144
- .set_global_filters(global_filters.unwrap_or_default());
177
+ let masters = match masters {
178
+ OptionalUpdate::Missing => None,
179
+ OptionalUpdate::SetDefault => Some(Vec::new()),
180
+ OptionalUpdate::Update(masters) => Some(
181
+ masters
182
+ .into_iter()
183
+ .filter_map(|saved| match id_map.get(&saved) {
184
+ Some(fresh) => Some(PanelId::from(fresh.as_str())),
185
+ None => {
186
+ tracing::warn!("`masters` names unknown panel \"{saved}\"");
187
+ None
188
+ },
189
+ })
190
+ .collect::<Vec<_>>(),
191
+ ),
192
+ };
193
+
194
+ if let Some(masters) = masters {
195
+ let before = this.workspace.masters();
196
+ this.workspace.set_masters(masters);
197
+ if !replace {
198
+ let after = this.workspace.masters();
199
+ for id in &old_ids {
200
+ let is_master = after.contains(id);
201
+ if before.contains(id) != is_master
202
+ && let Some(panel) = this.workspace.panel(id)
203
+ {
204
+ let mode = if is_master {
205
+ "SELECT_ROW_TREE"
206
+ } else {
207
+ "READ_ONLY"
208
+ };
209
+
210
+ set_edit_mode(&panel.session, &panel.renderer, mode);
211
+ if !is_master {
212
+ this.workspace.clear_contribution(id);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ match global_filters {
220
+ OptionalUpdate::Missing => {},
221
+ OptionalUpdate::SetDefault => this.workspace.set_global_filters(Vec::new()),
222
+ OptionalUpdate::Update(filters) => this.workspace.set_global_filters(filters),
223
+ }
224
+
145
225
  Ok((contents, eject_tasks))
146
226
  }
@@ -150,7 +150,13 @@ async fn bind_pending(
150
150
  return Ok(());
151
151
  };
152
152
 
153
- super::restore_panel::bind_table_task(&session, &workspace, name).await
153
+ super::restore_panel::bind_table_task(
154
+ &session,
155
+ &workspace,
156
+ name,
157
+ crate::session::MissingTable::Pend,
158
+ )
159
+ .await
154
160
  }
155
161
  },
156
162
  )
@@ -80,7 +80,7 @@ impl<T: TabItem> Component for TabList<T> {
80
80
 
81
81
  let onclick = ctx.link().callback(move |_| TabListMsg::SetSelected(idx));
82
82
  let title = tab.to_string();
83
- let style = intl_content_style(&format!("{}-tab", intl_slug(&title)), &title);
83
+ let style = intl_content_style(&format!("{}-tab", intl_slug(&title)));
84
84
  html! {
85
85
  <span {class} {onclick}>
86
86
  <div class="tab-title" id={title} {style} />
@@ -26,29 +26,10 @@ pub fn intl_slug(key: &str) -> String {
26
26
  .collect()
27
27
  }
28
28
 
29
- fn humanize(key: &str) -> String {
30
- let mut out = String::with_capacity(key.len());
31
- for x in key.chars() {
32
- if x.is_alphanumeric() {
33
- if out.is_empty() {
34
- out.extend(x.to_uppercase());
35
- } else {
36
- out.push(x);
37
- }
38
- } else if matches!(x, '_' | '-') || x.is_whitespace() {
39
- out.push(' ');
40
- }
41
- }
42
-
43
- out
44
- }
45
-
46
29
  /// The inline `style` that routes an element's `:before` text through the intl
47
- /// indirection: `--psp-label--content` is set to a reference to
48
- /// `--psp-label--{var}--content`, falling back to `fallback`, and a generic
49
- /// stylesheet rule renders it.
50
- pub fn intl_content_style(var: &str, fallback: &str) -> String {
51
- format!("--psp-label--content: var(--psp-label--{var}--content, \"{fallback}\")")
30
+ /// indirection.
31
+ pub fn intl_content_style(var: &str) -> String {
32
+ format!("--psp-label--content: var(--psp-label--{var}--content)")
52
33
  }
53
34
 
54
35
  #[derive(Properties, PartialEq)]
@@ -75,7 +56,7 @@ pub fn intl_label(props: &IntlLabelProps) -> Html {
75
56
  (format!("{}-label", props.name), slug)
76
57
  };
77
58
 
78
- let style = intl_content_style(&var, &humanize(&props.name));
59
+ let style = intl_content_style(&var);
79
60
  let class = classes!("intl-label", props.class.clone());
80
61
  html! { <label {class} {id} {style} /> }
81
62
  }
@@ -351,6 +351,7 @@ impl Component for PortalModal {
351
351
  if ctx.props().own_focus {
352
352
  self.host.set_attribute("tabindex", "0").unwrap();
353
353
  self.setup_dismiss_handlers(ctx);
354
+ let _ = self.host.focus();
354
355
  }
355
356
 
356
357
  let link = ctx.link().clone();
@@ -24,3 +24,16 @@ pub fn read_custom_property(elem: &Element, name: &str) -> Option<String> {
24
24
  let value = value.trim();
25
25
  (!value.is_empty()).then(|| value.to_owned())
26
26
  }
27
+
28
+ /// The intl string `--psp-label--{slug}--content` as inherited by `elem`,
29
+ /// unquoted, or `None` when no theme defines it.
30
+ pub fn read_intl_string(elem: &Element, slug: &str) -> Option<String> {
31
+ let raw = read_custom_property(elem, &format!("--psp-label--{slug}--content"))?;
32
+ let unquoted = raw
33
+ .strip_prefix('"')
34
+ .and_then(|x| x.strip_suffix('"'))
35
+ .or_else(|| raw.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')))
36
+ .unwrap_or(&raw);
37
+
38
+ Some(unquoted.replace("\\\"", "\""))
39
+ }
@@ -10,53 +10,65 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
- use std::fmt::Display;
14
- use std::str::FromStr;
13
+ use std::cell::Cell;
14
+ use std::rc::Rc;
15
15
 
16
- use serde::{Deserialize, Serialize};
17
- use strum::EnumIter;
18
- use ts_rs::TS;
16
+ use super::pubsub::PubSub;
19
17
 
20
- #[derive(Clone, Copy, Debug, Default, Deserialize, EnumIter, Eq, PartialEq, Serialize, TS)]
21
- pub enum DatetimeColorMode {
22
- #[default]
23
- #[serde(rename = "none")]
24
- None,
18
+ /// A refcounted account of concurrent in-flight work, moved only by
19
+ /// [`InFlightGuard`]'s `Drop` so no exit path can strand the count.
20
+ #[derive(Clone, Default)]
21
+ pub struct InFlight(Rc<InFlightData>);
25
22
 
26
- #[serde(rename = "foreground")]
27
- Foreground,
28
-
29
- #[serde(rename = "background")]
30
- Background,
23
+ #[derive(Default)]
24
+ struct InFlightData {
25
+ count: Cell<u32>,
26
+ changed: PubSub<u32>,
31
27
  }
32
28
 
33
- impl Display for DatetimeColorMode {
34
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35
- let text = match self {
36
- Self::None => "none",
37
- Self::Foreground => "foreground",
38
- Self::Background => "background",
39
- };
29
+ /// One unit of in-flight work, moved INTO the future it accounts for.
30
+ #[must_use]
31
+ pub struct InFlightGuard(InFlight);
40
32
 
41
- write!(f, "{text}")
33
+ impl InFlight {
34
+ pub fn guard(&self) -> InFlightGuard {
35
+ let count = self.0.count.get() + 1;
36
+ self.0.count.set(count);
37
+ self.0.changed.emit(count);
38
+ InFlightGuard(self.clone())
39
+ }
40
+
41
+ pub fn count(&self) -> u32 {
42
+ self.0.count.get()
42
43
  }
43
- }
44
44
 
45
- impl FromStr for DatetimeColorMode {
46
- type Err = String;
45
+ pub fn is_empty(&self) -> bool {
46
+ self.0.count.get() == 0
47
+ }
48
+
49
+ /// Fires with the ABSOLUTE [`Self::count`] on both edges, so subscribers
50
+ /// assign rather than accumulate.
51
+ pub fn changed(&self) -> &PubSub<u32> {
52
+ &self.0.changed
53
+ }
47
54
 
48
- fn from_str(s: &str) -> Result<Self, Self::Err> {
49
- match s {
50
- "none" => Ok(Self::None),
51
- "foreground" => Ok(Self::Foreground),
52
- "background" => Ok(Self::Background),
53
- x => Err(format!("Unknown DatetimeColorMode::{x}")),
55
+ /// Resolve once every unit in flight has settled.
56
+ pub async fn settle(&self) {
57
+ while self.0.count.get() > 0 {
58
+ if self.0.changed.read_next().await.is_err() {
59
+ break;
60
+ }
54
61
  }
55
62
  }
56
63
  }
57
64
 
58
- impl DatetimeColorMode {
59
- pub fn is_none(&self) -> bool {
60
- self == &Self::None
65
+ impl Drop for InFlightGuard {
66
+ fn drop(&mut self) {
67
+ let data = &self.0.0;
68
+ let count = data.count.get();
69
+ debug_assert!(count > 0, "InFlight underflow");
70
+ let count = count.saturating_sub(1);
71
+ data.count.set(count);
72
+ data.changed.emit(count);
61
73
  }
62
74
  }