@perspective-dev/viewer 4.5.1 → 5.0.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 (212) 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/monokai.css +1 -1
  9. package/dist/css/phosphor.css +1 -1
  10. package/dist/css/pro-dark.css +1 -1
  11. package/dist/css/pro.css +1 -1
  12. package/dist/css/solarized-dark.css +1 -1
  13. package/dist/css/solarized.css +1 -1
  14. package/dist/css/themes.css +1 -1
  15. package/dist/css/vaporwave.css +1 -1
  16. package/dist/esm/extensions.d.ts +22 -3
  17. package/dist/esm/perspective-viewer.cdn.d.ts +3 -0
  18. package/dist/esm/perspective-viewer.d.ts +9 -0
  19. package/dist/esm/perspective-viewer.inline.d.ts +3 -0
  20. package/dist/esm/perspective-viewer.inline.js +20 -1
  21. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  22. package/dist/esm/perspective-viewer.js +20 -1
  23. package/dist/esm/perspective-viewer.js.map +4 -4
  24. package/dist/esm/plugin.d.ts +71 -5
  25. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  26. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  27. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  28. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  29. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  30. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  31. package/dist/esm/ts-rs/OnUpdateData.d.ts +6 -0
  32. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  33. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  34. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +39 -0
  35. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  36. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  37. package/dist/esm/ts-rs/ViewOnUpdateResp.d.ts +4 -0
  38. package/dist/esm/ts-rs/ViewerConfig.d.ts +6 -2
  39. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  40. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  41. package/dist/wasm/perspective-viewer.d.ts +213 -79
  42. package/dist/wasm/perspective-viewer.js +537 -189
  43. package/dist/wasm/perspective-viewer.wasm +0 -0
  44. package/dist/wasm/perspective-viewer.wasm.d.ts +36 -28
  45. package/dist/wasm/snippets/perspective-viewer-daeef4ceb066e6c7/inline0.js +6 -0
  46. package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline3.js → perspective-viewer-daeef4ceb066e6c7/inline4.js} +5 -0
  47. package/package.json +3 -2
  48. package/src/css/_column_dropdown.css +14 -0
  49. package/src/css/_context_menu.css +14 -0
  50. package/src/css/_dropdown_menu.css +14 -0
  51. package/src/css/_filter_dropdown.css +14 -0
  52. package/src/css/_function_dropdown.css +14 -0
  53. package/src/css/_viewer_bundle.css +41 -0
  54. package/src/css/column-settings-panel.css +1 -1
  55. package/src/css/containers/context-menu.css +101 -0
  56. package/src/css/panel-tab.css +193 -0
  57. package/src/css/plugin-selector.css +7 -10
  58. package/src/css/status-bar.css +75 -81
  59. package/src/css/viewer.css +181 -71
  60. package/src/rust/components/column_dropdown.rs +43 -33
  61. package/src/rust/components/column_selector/active_column.rs +7 -18
  62. package/src/rust/components/column_selector/aggregate_selector.rs +4 -10
  63. package/src/rust/components/column_selector/config_selector.rs +33 -84
  64. package/src/rust/components/column_selector/empty_column.rs +0 -3
  65. package/src/rust/components/column_selector/filter_column.rs +5 -18
  66. package/src/rust/components/column_selector/inactive_column.rs +3 -8
  67. package/src/rust/components/column_selector/invalid_column.rs +0 -4
  68. package/src/rust/components/column_selector/sort_column.rs +3 -6
  69. package/src/rust/components/column_selector.rs +5 -14
  70. package/src/rust/components/column_settings_sidebar/style_tab/symbol/symbol_pairs.rs +0 -3
  71. package/src/rust/components/column_settings_sidebar/style_tab/symbol.rs +0 -3
  72. package/src/rust/components/column_settings_sidebar.rs +0 -3
  73. package/src/rust/components/containers/dropdown_menu.rs +1 -3
  74. package/src/rust/components/containers/scroll_panel.rs +1 -3
  75. package/src/rust/components/containers/split_panel.rs +175 -66
  76. package/src/rust/components/containers/tab_list.rs +0 -3
  77. package/src/rust/components/context_menu.rs +130 -0
  78. package/src/rust/components/datetime_column_style.rs +0 -3
  79. package/src/rust/components/empty_row.rs +0 -3
  80. package/src/rust/components/expression_editor.rs +2 -3
  81. package/src/rust/components/filter_dropdown.rs +3 -3
  82. package/src/rust/components/font_loader.rs +2 -2
  83. package/src/rust/components/form/code_editor.rs +0 -3
  84. package/src/rust/components/form/debug.rs +3 -4
  85. package/src/rust/components/function_dropdown.rs +3 -3
  86. package/src/rust/components/global_filter_bar.rs +76 -0
  87. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  88. package/src/rust/components/main_panel/msg.rs +62 -0
  89. package/src/rust/components/main_panel/presize.rs +220 -0
  90. package/src/rust/components/main_panel/reconcile.rs +224 -0
  91. package/src/rust/components/main_panel/render.rs +385 -0
  92. package/src/rust/components/main_panel/update.rs +184 -0
  93. package/src/rust/components/main_panel.rs +311 -71
  94. package/src/rust/components/mod.rs +4 -0
  95. package/src/rust/components/number_series_style.rs +0 -3
  96. package/src/rust/components/panel_menu.rs +486 -0
  97. package/src/rust/components/panel_tab.rs +505 -0
  98. package/src/rust/components/plugin_selector.rs +0 -3
  99. package/src/rust/components/plugin_tab.rs +1 -6
  100. package/src/rust/components/portal.rs +5 -2
  101. package/src/rust/components/render_warning.rs +0 -3
  102. package/src/rust/components/settings_panel.rs +13 -5
  103. package/src/rust/components/status_bar.rs +113 -180
  104. package/src/rust/components/status_indicator.rs +6 -9
  105. package/src/rust/components/string_column_style.rs +0 -3
  106. package/src/rust/components/style/mod.rs +65 -27
  107. package/src/rust/components/style/style_provider.rs +51 -16
  108. package/src/rust/components/style_controls/number_string_format.rs +1 -8
  109. package/src/rust/components/type_icon.rs +1 -4
  110. package/src/rust/components/viewer/filters.rs +133 -0
  111. package/src/rust/components/viewer/msg.rs +180 -0
  112. package/src/rust/components/viewer/panels.rs +326 -0
  113. package/src/rust/components/viewer/render.rs +243 -0
  114. package/src/rust/components/viewer/settings.rs +402 -0
  115. package/src/rust/components/viewer/snapshots.rs +134 -0
  116. package/src/rust/components/viewer/wiring.rs +505 -0
  117. package/src/rust/components/viewer.rs +243 -758
  118. package/src/rust/config/export_method.rs +1 -1
  119. package/src/rust/config/mod.rs +4 -0
  120. package/src/rust/config/options.rs +71 -0
  121. package/src/rust/config/viewer_config.rs +24 -2
  122. package/src/rust/config/workspace_config.rs +92 -0
  123. package/src/rust/custom_elements/copy_dropdown.rs +9 -3
  124. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  125. package/src/rust/custom_elements/export_dropdown.rs +9 -3
  126. package/src/rust/custom_elements/viewer.rs +1015 -286
  127. package/src/rust/custom_events.rs +212 -61
  128. package/src/rust/js/mod.rs +2 -0
  129. package/src/rust/js/plugin.rs +297 -10
  130. package/src/rust/js/regular_layout.rs +258 -0
  131. package/src/rust/lib.rs +20 -2
  132. package/src/rust/presentation.rs +66 -33
  133. package/src/rust/queries/get_viewer_config.rs +29 -11
  134. package/src/rust/queries/plugin_column_styles.rs +2 -2
  135. package/src/rust/renderer/activate.rs +41 -14
  136. package/src/rust/renderer/dispatch.rs +436 -0
  137. package/src/rust/renderer/plugin_config.rs +495 -0
  138. package/src/rust/renderer/registry.rs +18 -6
  139. package/src/rust/renderer/render_timer.rs +29 -10
  140. package/src/rust/renderer.rs +350 -827
  141. package/src/rust/session/column_defaults_update.rs +26 -8
  142. package/src/rust/session/metadata.rs +11 -1
  143. package/src/rust/session/view_subscription.rs +30 -15
  144. package/src/rust/session.rs +753 -251
  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/eject.rs +64 -23
  152. package/src/rust/tasks/mod.rs +28 -4
  153. package/src/rust/tasks/pipeline.rs +411 -0
  154. package/src/rust/tasks/presize_panels.rs +324 -0
  155. package/src/rust/tasks/reset_all.rs +22 -12
  156. package/src/rust/tasks/resize_observer.rs +124 -87
  157. package/src/rust/tasks/restore_and_render.rs +80 -86
  158. package/src/rust/tasks/restore_panel.rs +145 -0
  159. package/src/rust/tasks/send_column_config.rs +5 -2
  160. package/src/rust/tasks/send_plugin_config.rs +5 -2
  161. package/src/rust/tasks/set_edit_mode.rs +59 -0
  162. package/src/rust/tasks/sync_update_panels.rs +152 -0
  163. package/src/rust/tasks/table_lifecycle.rs +158 -0
  164. package/src/rust/tasks/update_theme.rs +56 -12
  165. package/src/rust/utils/completion.rs +56 -0
  166. package/src/rust/utils/custom_element.rs +5 -0
  167. package/src/rust/utils/debounce.rs +130 -7
  168. package/src/rust/utils/mod.rs +6 -0
  169. package/src/rust/utils/pending_effects.rs +62 -0
  170. package/src/rust/{components/style/local_style.rs → utils/spawn.rs} +17 -19
  171. package/src/rust/utils/weak_scope.rs +0 -10
  172. package/src/rust/workspace.rs +846 -0
  173. package/src/svg/active-indicator.svg +3 -0
  174. package/src/themes/botanical.css +7 -2
  175. package/src/themes/defaults.css +54 -2
  176. package/src/themes/dracula.css +8 -3
  177. package/src/themes/gruvbox-dark.css +7 -2
  178. package/src/themes/gruvbox.css +5 -2
  179. package/src/themes/icons.css +4 -2
  180. package/src/themes/monokai.css +7 -2
  181. package/src/themes/phosphor.css +7 -2
  182. package/src/themes/pro-dark.css +7 -2
  183. package/src/themes/pro.css +2 -1
  184. package/src/themes/solarized-dark.css +5 -2
  185. package/src/themes/solarized.css +5 -2
  186. package/src/themes/vaporwave.css +7 -2
  187. package/src/ts/bootstrap.ts +8 -7
  188. package/src/ts/extensions.ts +30 -2
  189. package/src/ts/perspective-viewer.ts +10 -0
  190. package/src/ts/plugin.ts +72 -5
  191. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  192. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  193. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  194. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  195. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  196. package/src/ts/ts-rs/Layout.ts +9 -0
  197. package/src/ts/ts-rs/Orientation.ts +6 -0
  198. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  199. package/src/ts/ts-rs/PanelViewerConfig.ts +15 -0
  200. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  201. package/src/ts/ts-rs/TabLayout.ts +11 -0
  202. package/src/ts/ts-rs/ViewerConfig.ts +6 -2
  203. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  204. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  205. package/tsconfig.json +1 -1
  206. package/src/rust/components/style/style_cache.rs +0 -136
  207. package/src/rust/tasks/intersection_observer.rs +0 -98
  208. package/src/rust/tasks/update_and_render.rs +0 -110
  209. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline0.js → perspective-viewer-daeef4ceb066e6c7/inline1.js} +0 -0
  210. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline1.js → perspective-viewer-daeef4ceb066e6c7/inline2.js} +0 -0
  211. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline2.js → perspective-viewer-daeef4ceb066e6c7/inline3.js} +0 -0
  212. /package/dist/wasm/snippets/{perspective-viewer-3cd58f0374935772/inline4.js → perspective-viewer-daeef4ceb066e6c7/inline5.js} +0 -0
@@ -0,0 +1,505 @@
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
+ //! Engine → root wiring: the PubSub subscriptions and direct callbacks that
14
+ //! reflect [`Session`]/[`Renderer`]/[`Presentation`] state into the root's
15
+ //! value-semantic snapshots, plus the raw window/host DOM listeners the root
16
+ //! owns (shift-key affordance, master/detail selection).
17
+
18
+ use std::collections::HashMap;
19
+
20
+ use perspective_client::config::{Filter, FilterTerm, Scalar};
21
+ use perspective_js::utils::*;
22
+ use wasm_bindgen::JsCast;
23
+ use wasm_bindgen::prelude::*;
24
+ use web_sys::{FocusEvent, KeyboardEvent};
25
+ use yew::prelude::*;
26
+
27
+ use super::PerspectiveViewer;
28
+ use super::msg::MasterSelection;
29
+ use super::msg::PerspectiveViewerMsg::{self, *};
30
+ use crate::js::JsPerspectiveViewerPlugin;
31
+ use crate::queries::*;
32
+ use crate::renderer::*;
33
+ use crate::session::*;
34
+ use crate::utils::*;
35
+
36
+ /// Window listeners that toggle the `.shift-active` class on the host element
37
+ /// while the Shift key is held, making Shift-modified affordances (e.g.
38
+ /// inactive column add, active column remove, status-bar reset) visually
39
+ /// discoverable. Stored so the closures outlive `create`.
40
+ pub(super) struct ShiftListeners {
41
+ elem: web_sys::HtmlElement,
42
+ keydown: Closure<dyn FnMut(KeyboardEvent)>,
43
+ keyup: Closure<dyn FnMut(KeyboardEvent)>,
44
+ blur: Closure<dyn FnMut(FocusEvent)>,
45
+ }
46
+
47
+ impl Drop for ShiftListeners {
48
+ fn drop(&mut self) {
49
+ let win = global::window();
50
+ let _ = win
51
+ .remove_event_listener_with_callback("keydown", self.keydown.as_ref().unchecked_ref());
52
+ let _ =
53
+ win.remove_event_listener_with_callback("keyup", self.keyup.as_ref().unchecked_ref());
54
+ let _ = win.remove_event_listener_with_callback("blur", self.blur.as_ref().unchecked_ref());
55
+ let _ = self.elem.class_list().remove_1("shift-active");
56
+ }
57
+ }
58
+
59
+ pub(super) fn install_shift_listeners(elem: web_sys::HtmlElement) -> ShiftListeners {
60
+ let keydown = {
61
+ let elem = elem.clone();
62
+ Closure::wrap(Box::new(move |event: KeyboardEvent| {
63
+ if event.key() == "Shift" {
64
+ let _ = elem.class_list().add_1("shift-active");
65
+ }
66
+ }) as Box<dyn FnMut(KeyboardEvent)>)
67
+ };
68
+
69
+ let keyup = {
70
+ let elem = elem.clone();
71
+ Closure::wrap(Box::new(move |event: KeyboardEvent| {
72
+ if event.key() == "Shift" {
73
+ let _ = elem.class_list().remove_1("shift-active");
74
+ }
75
+ }) as Box<dyn FnMut(KeyboardEvent)>)
76
+ };
77
+
78
+ let blur = {
79
+ let elem = elem.clone();
80
+ Closure::wrap(Box::new(move |_: FocusEvent| {
81
+ let _ = elem.class_list().remove_1("shift-active");
82
+ }) as Box<dyn FnMut(FocusEvent)>)
83
+ };
84
+
85
+ let win = global::window();
86
+ let _ = win.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref());
87
+ let _ = win.add_event_listener_with_callback("keyup", keyup.as_ref().unchecked_ref());
88
+ let _ = win.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref());
89
+
90
+ ShiftListeners {
91
+ elem,
92
+ keydown,
93
+ keyup,
94
+ blur,
95
+ }
96
+ }
97
+
98
+ /// The `perspective-global-filter` host listener (master/detail): routes a
99
+ /// master panel's selection state to
100
+ /// [`PerspectiveViewerMsg::MasterContribution`] — `selected: true` REPLACES
101
+ /// the panel's contribution with the event's filters, `selected: false`
102
+ /// clears it. Kept alive on the root; attached once in `rendered`.
103
+ pub(super) fn global_filter_listener(
104
+ ctx: &Context<PerspectiveViewer>,
105
+ ) -> Closure<dyn FnMut(web_sys::Event)> {
106
+ let cb = ctx
107
+ .link()
108
+ .callback(|(panel, selection)| PerspectiveViewerMsg::MasterContribution(panel, selection));
109
+
110
+ Closure::wrap(Box::new(move |event: web_sys::Event| {
111
+ // `PerspectiveSelectDetail` exposes `selected`/`insertFilters` as
112
+ // prototype *getters*; struct deserialization reads each field by
113
+ // named property access (not own-key enumeration), so the getters are
114
+ // invoked — a map target would see none of them. Field names mirror
115
+ // the detail EXACTLY — it mixes `insertFilters` (camel) with
116
+ // `column_names` (snake), so no `rename_all`.
117
+ #[derive(serde::Deserialize)]
118
+ struct SelectDetail {
119
+ // Only multi-panel selections (carrying a source `panel` id) route.
120
+ panel: Option<String>,
121
+
122
+ #[serde(default)]
123
+ selected: bool,
124
+
125
+ #[serde(default, rename = "insertFilters")]
126
+ insert_filters: Vec<Filter>,
127
+
128
+ #[serde(default)]
129
+ row: HashMap<String, serde_json::Value>,
130
+
131
+ #[serde(default)]
132
+ column_names: Vec<serde_json::Value>,
133
+ }
134
+
135
+ let Some(ce) = event.dyn_ref::<web_sys::CustomEvent>() else {
136
+ return;
137
+ };
138
+
139
+ let Ok(SelectDetail {
140
+ panel: Some(panel),
141
+ selected,
142
+ insert_filters,
143
+ row,
144
+ column_names,
145
+ }) = ce.detail().into_serde_ext()
146
+ else {
147
+ return;
148
+ };
149
+
150
+ let selection = selected.then(|| MasterSelection {
151
+ cell_fallback: cell_fallback(&row, &column_names),
152
+ filters: insert_filters,
153
+ });
154
+
155
+ cb.emit((panel, selection));
156
+ }) as Box<dyn FnMut(web_sys::Event)>)
157
+ }
158
+
159
+ /// The `perspective-click` host listener: a click on a MASTER panel is a
160
+ /// selection too (chart bars, flat datagrids — plugins whose click carries
161
+ /// the clicked datum but which have no select mode), routed as an
162
+ /// always-`Some` [`PerspectiveViewerMsg::MasterContribution`]. Clicks have no
163
+ /// deselect arm — deselection arrives via the `perspective-global-filter`
164
+ /// listener or the bar's chip ×. Non-master panels' clicks are dropped by the
165
+ /// handler's `is_master` guard, and the datagrid suppresses clicks entirely
166
+ /// in its row-select mode so a select/deselect never races a click echo.
167
+ pub(super) fn master_click_listener(
168
+ ctx: &Context<PerspectiveViewer>,
169
+ ) -> Closure<dyn FnMut(web_sys::Event)> {
170
+ let cb = ctx
171
+ .link()
172
+ .callback(|(panel, selection)| PerspectiveViewerMsg::MasterContribution(panel, selection));
173
+
174
+ Closure::wrap(Box::new(move |event: web_sys::Event| {
175
+ #[derive(serde::Deserialize)]
176
+ struct ClickConfig {
177
+ #[serde(default)]
178
+ filter: Vec<Filter>,
179
+ }
180
+
181
+ #[derive(serde::Deserialize)]
182
+ struct ClickDetail {
183
+ // Only multi-panel clicks (carrying a source `panel` id) route.
184
+ panel: Option<String>,
185
+
186
+ #[serde(default)]
187
+ config: Option<ClickConfig>,
188
+
189
+ #[serde(default)]
190
+ row: HashMap<String, serde_json::Value>,
191
+
192
+ #[serde(default)]
193
+ column_names: Vec<serde_json::Value>,
194
+ }
195
+
196
+ let Some(ce) = event.dyn_ref::<web_sys::CustomEvent>() else {
197
+ return;
198
+ };
199
+
200
+ let Ok(ClickDetail {
201
+ panel: Some(panel),
202
+ config,
203
+ row,
204
+ column_names,
205
+ }) = ce.detail().into_serde_ext()
206
+ else {
207
+ return;
208
+ };
209
+
210
+ let selection = MasterSelection {
211
+ cell_fallback: cell_fallback(&row, &column_names),
212
+ filters: config.map(|x| x.filter).unwrap_or_default(),
213
+ };
214
+
215
+ cb.emit((panel, Some(selection)));
216
+ }) as Box<dyn FnMut(web_sys::Event)>)
217
+ }
218
+
219
+ /// Synthesize the clicked-cell `[column, "==", value]` fallback clause from a
220
+ /// select/click detail's `row` + `column_names` — the broadcast for masters
221
+ /// whose events carry no derivable filters (e.g. a FLAT datagrid, whose rows
222
+ /// have no group-by path). The first real (non-`__`-internal, string-named)
223
+ /// column with a scalar value in the row wins.
224
+ fn cell_fallback(
225
+ row: &HashMap<String, serde_json::Value>,
226
+ column_names: &[serde_json::Value],
227
+ ) -> Option<Filter> {
228
+ let column = column_names
229
+ .iter()
230
+ .filter_map(|x| x.as_str())
231
+ .find(|x| !x.starts_with("__"))?;
232
+
233
+ let term = match row.get(column)? {
234
+ serde_json::Value::Bool(x) => Scalar::Bool(*x),
235
+ serde_json::Value::Number(x) => Scalar::Float(x.as_f64()?),
236
+ serde_json::Value::String(x) => Scalar::String(x.clone()),
237
+ _ => return None,
238
+ };
239
+
240
+ Some(Filter::new(column, "==", FilterTerm::Scalar(term)))
241
+ }
242
+
243
+ /// Subscribe to *every* panel's `title_changed` so a title change on any panel
244
+ /// (not just the active one — e.g. a non-active panel restored via
245
+ /// `restorePanel`) re-renders the tab titles. Recreated when the panel set
246
+ /// changes.
247
+ pub(super) fn subscribe_panel_titles(ctx: &Context<PerspectiveViewer>) -> Vec<Subscription> {
248
+ let cb = ctx.link().callback(|_| PerspectiveViewerMsg::TitlesChanged);
249
+ ctx.props()
250
+ .workspace
251
+ .panel_ids()
252
+ .iter()
253
+ .filter_map(|id| ctx.props().workspace.panel(id))
254
+ .flat_map(|p| {
255
+ // Re-collect titles on a title change *or* a table (un)load — the
256
+ // latter so the untitled table-name fallback appears once a table
257
+ // binds to the panel.
258
+ [
259
+ p.session.title_changed.add_notify_listener(&cb),
260
+ p.session.table_loaded.add_notify_listener(&cb),
261
+ ]
262
+ })
263
+ .collect()
264
+ }
265
+
266
+ /// Per-active-panel subscriptions: reflect the *active* panel's
267
+ /// session/renderer PubSub events into the root's snapshots. Dropped +
268
+ /// recreated against the new active panel on `SetActivePanel` (pair with
269
+ /// [`clear_active_callbacks`] for the direct-callback half).
270
+ pub(super) fn create_active_subscriptions(
271
+ ctx: &Context<PerspectiveViewer>,
272
+ session: &Session,
273
+ renderer: &Renderer,
274
+ ) -> Vec<Subscription> {
275
+ let session_props_sub = {
276
+ let s = session.clone();
277
+ let cb = ctx
278
+ .link()
279
+ .callback(move |_: ()| UpdateSession(Box::new(s.to_props())));
280
+
281
+ let sub1 = session.table_loaded.add_notify_listener(&cb);
282
+ let sub2 = session.table_unloaded.add_notify_listener(&cb);
283
+ // Snapshot refresh on every RECONCILED commit (SKIP/REUSE/paused
284
+ // included), not just `View` construction.
285
+ let sub3 = session.commit_reconciled.add_notify_listener(&cb);
286
+ let sub4 = session.view_config_changed.add_notify_listener(&cb);
287
+ let sub5 = session.title_changed.add_notify_listener(&cb);
288
+
289
+ // The `StatusIndicator` spinner count. LEVEL-triggered: the payload
290
+ // is the ABSOLUTE in-flight config-run count (RAII-settled by
291
+ // `ConfigRunToken`), which the handler ASSIGNS — replacing the
292
+ // edge-counted `view_config_changed`+1/`view_created`−1 pair, whose
293
+ // pairing the pipeline does not guarantee (stuck-spinner
294
+ // regression — see `UPDATE_COUNT_REGRESSION_PLAN.md`).
295
+ let sub6 = session
296
+ .run_state_changed
297
+ .add_listener(ctx.link().callback(UpdateInFlight));
298
+
299
+ // Stats fetch resolution (populates session.column_stats) triggers
300
+ // a fresh `SessionProps` so `column_stats` reaches downstream
301
+ // components and the StyleTab re-queries the schema with the
302
+ // new value.
303
+ let sub7 = session.column_stats_changed.add_notify_listener(&cb);
304
+
305
+ vec![sub1, sub2, sub3, sub4, sub5, sub6, sub7]
306
+ };
307
+
308
+ let renderer_props_sub = {
309
+ let cb_plugin = ctx.link().callback({
310
+ let renderer = renderer.clone();
311
+ move |_: JsPerspectiveViewerPlugin| UpdateRenderer(Box::new(renderer.to_props(None)))
312
+ });
313
+
314
+ // Re-snapshot RendererProps when the plugin_config bucket
315
+ // changes (in-tab edit via `send_plugin_config`, JSON paste via
316
+ // `restore_and_render`, full clear via `reset_all` with
317
+ // `all=true`). Without this, `RendererProps.plugin_config`
318
+ // would stay frozen at its construct-time value and `PluginTab`
319
+ // would render stale.
320
+ let cb_plugin_config = ctx.link().callback({
321
+ let renderer = renderer.clone();
322
+ move |_: serde_json::Map<String, serde_json::Value>| {
323
+ UpdateRenderer(Box::new(renderer.to_props(None)))
324
+ }
325
+ });
326
+
327
+ let sub1 = renderer.plugin_changed.add_listener(cb_plugin);
328
+ let sub2 = renderer
329
+ .plugin_config_changed
330
+ .add_listener(cb_plugin_config);
331
+
332
+ vec![sub1, sub2]
333
+ };
334
+
335
+ let mut subscriptions = Vec::new();
336
+ subscriptions.extend(session_props_sub);
337
+ subscriptions.extend(renderer_props_sub);
338
+ subscriptions
339
+ }
340
+
341
+ /// Element-level (shared, not per-panel) subscriptions: presentation +
342
+ /// drag/drop. Created once at construction.
343
+ pub(super) fn create_shared_subscriptions(ctx: &Context<PerspectiveViewer>) -> Vec<Subscription> {
344
+ let presentation_props_sub = {
345
+ let presentation = ctx.props().presentation.clone();
346
+ let cb_settings = ctx.link().callback(UpdateSettingsOpen);
347
+ let cb_theme = {
348
+ let pres = presentation.clone();
349
+ ctx.link()
350
+ .callback(move |(themes, _): (PtrEqRc<Vec<String>>, _)| {
351
+ UpdatePresentation(Box::new(pres.to_props(themes)))
352
+ })
353
+ };
354
+
355
+ let cb_column_settings = {
356
+ let pres = presentation.clone();
357
+ ctx.link().callback(move |_: (bool, Option<String>)| {
358
+ UpdateColumnSettings(Box::new(pres.get_open_column_settings()))
359
+ })
360
+ };
361
+
362
+ let sub1 = presentation.settings_open_changed.add_listener(cb_settings);
363
+ let sub2 = presentation.theme_config_updated.add_listener(cb_theme);
364
+ let sub3 = presentation
365
+ .column_settings_open_changed
366
+ .add_listener(cb_column_settings);
367
+
368
+ vec![sub1, sub2, sub3]
369
+ };
370
+
371
+ let dragdrop_props_sub = {
372
+ let cb_clear = ctx.link().callback(|_: ()| UpdateDragDrop(Box::default()));
373
+ let sub1 = ctx
374
+ .props()
375
+ .presentation
376
+ .drop_received
377
+ .add_notify_listener(&cb_clear);
378
+
379
+ vec![sub1]
380
+ };
381
+
382
+ // The `Workspace`-owned global filter set → the root's render snapshot.
383
+ let filters_sub = {
384
+ let cb = ctx.link().callback(|_: ()| UpdateGlobalFilters);
385
+ ctx.props()
386
+ .workspace
387
+ .filters_changed()
388
+ .add_notify_listener(&cb)
389
+ };
390
+
391
+ // Staged-set transitions (`Workspace::stage_panel`/`clear_staged`) → a
392
+ // root re-render for the stage-level chrome (`only-child` class,
393
+ // binding resync). `MainPanel` inserts/reveals promoted panels via its
394
+ // OWN `staged_changed` subscription (`MainPanelMsg::StagedChanged`) —
395
+ // see `WorkspaceData::staged_changed`.
396
+ let staged_sub = {
397
+ let cb = ctx.link().callback(|_: ()| LayoutChanged);
398
+ ctx.props()
399
+ .workspace
400
+ .staged_changed()
401
+ .add_notify_listener(&cb)
402
+ };
403
+
404
+ let mut subscriptions = Vec::new();
405
+ subscriptions.extend(presentation_props_sub);
406
+ subscriptions.extend(dragdrop_props_sub);
407
+ subscriptions.push(filters_sub);
408
+ subscriptions.push(staged_sub);
409
+ subscriptions
410
+ }
411
+
412
+ /// Inject the *active* panel's session/renderer direct callbacks into its
413
+ /// engine handles. Cleared via [`clear_active_callbacks`] before re-targeting
414
+ /// on `SetActivePanel`.
415
+ pub(super) fn inject_active_callbacks(
416
+ ctx: &Context<PerspectiveViewer>,
417
+ session: &Session,
418
+ renderer: &Renderer,
419
+ ) {
420
+ // Session: on_stats_changed
421
+ {
422
+ let s = session.clone();
423
+ let cb = ctx
424
+ .link()
425
+ .callback(move |_: ()| UpdateSessionStats(s.get_table_stats(), s.has_table()));
426
+
427
+ *session.on_stats_changed.borrow_mut() = Some(cb);
428
+ }
429
+
430
+ // Session: on_table_errored
431
+ {
432
+ let s = session.clone();
433
+ let cb = ctx
434
+ .link()
435
+ .callback(move |_: ()| UpdateSession(Box::new(s.to_props())));
436
+
437
+ *session.on_table_errored.borrow_mut() = Some(cb);
438
+ }
439
+
440
+ // Renderer: on_render_limits_changed (combines UpdateRenderer + column
441
+ // locator recheck that were previously two separate PubSub subscriptions).
442
+ {
443
+ let presentation = ctx.props().presentation.clone();
444
+ let r = renderer.clone();
445
+ let s = session.clone();
446
+ let cb = ctx.link().batch_callback(move |limits: RenderLimits| {
447
+ let mut msgs = vec![UpdateRenderer(Box::new(r.to_props(Some(limits))))];
448
+ if !limits.is_update {
449
+ let locator = get_current_column_locator(
450
+ &presentation.get_open_column_settings(),
451
+ &r,
452
+ &s.get_view_config(),
453
+ &s.metadata(),
454
+ );
455
+
456
+ msgs.push(OpenColumnSettings {
457
+ locator,
458
+ sender: None,
459
+ toggle: false,
460
+ });
461
+ }
462
+
463
+ msgs
464
+ });
465
+
466
+ *renderer.on_render_limits_changed.borrow_mut() = Some(cb);
467
+ }
468
+ }
469
+
470
+ /// Clear the active panel's session/renderer direct callbacks so a
471
+ /// no-longer-active panel stops driving the root's snapshots.
472
+ pub(super) fn clear_active_callbacks(session: &Session, renderer: &Renderer) {
473
+ *session.on_stats_changed.borrow_mut() = None;
474
+ *session.on_table_errored.borrow_mut() = None;
475
+ *renderer.on_render_limits_changed.borrow_mut() = None;
476
+ }
477
+
478
+ /// Inject element-level (shared) presentation callbacks. Created once at
479
+ /// construction.
480
+ pub(super) fn inject_shared_callbacks(ctx: &Context<PerspectiveViewer>) {
481
+ // Presentation: on_is_workspace_changed
482
+ {
483
+ let cb = ctx.link().callback(UpdateIsWorkspace);
484
+ *ctx.props()
485
+ .presentation
486
+ .on_is_workspace_changed
487
+ .borrow_mut() = Some(cb);
488
+ }
489
+
490
+ // Drag/drop: on_dragstart (post-merge: lives on Presentation)
491
+ {
492
+ let presentation = ctx.props().presentation.clone();
493
+ let cb = ctx.link().callback(move |_: DragEffect| {
494
+ UpdateDragDrop(Box::new(presentation.drag_drop_props()))
495
+ });
496
+
497
+ *ctx.props().presentation.on_dragstart.borrow_mut() = Some(cb);
498
+ }
499
+
500
+ // Drag/drop: on_dragend
501
+ {
502
+ let cb = ctx.link().callback(|_: ()| UpdateDragDrop(Box::default()));
503
+ *ctx.props().presentation.on_dragend.borrow_mut() = Some(cb);
504
+ }
505
+ }