@perspective-dev/viewer 4.5.2 → 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 (178) 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.d.ts +9 -0
  18. package/dist/esm/perspective-viewer.inline.js +20 -1
  19. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  20. package/dist/esm/perspective-viewer.js +20 -1
  21. package/dist/esm/perspective-viewer.js.map +4 -4
  22. package/dist/esm/plugin.d.ts +71 -5
  23. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  24. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  25. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  26. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  27. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  28. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  29. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  30. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  31. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +39 -0
  32. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  33. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  34. package/dist/esm/ts-rs/ViewerConfig.d.ts +6 -2
  35. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  36. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  37. package/dist/wasm/perspective-viewer.d.ts +211 -77
  38. package/dist/wasm/perspective-viewer.js +528 -180
  39. package/dist/wasm/perspective-viewer.wasm +0 -0
  40. package/dist/wasm/perspective-viewer.wasm.d.ts +34 -26
  41. package/dist/wasm/snippets/perspective-viewer-daeef4ceb066e6c7/inline0.js +6 -0
  42. package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline3.js → perspective-viewer-daeef4ceb066e6c7/inline4.js} +5 -0
  43. package/package.json +3 -2
  44. package/src/css/_context_menu.css +14 -0
  45. package/src/css/column-settings-panel.css +1 -1
  46. package/src/css/containers/context-menu.css +101 -0
  47. package/src/css/panel-tab.css +193 -0
  48. package/src/css/plugin-selector.css +7 -10
  49. package/src/css/status-bar.css +75 -81
  50. package/src/css/viewer.css +180 -70
  51. package/src/rust/components/column_dropdown.rs +40 -30
  52. package/src/rust/components/column_selector/active_column.rs +7 -18
  53. package/src/rust/components/column_selector/aggregate_selector.rs +4 -7
  54. package/src/rust/components/column_selector/config_selector.rs +33 -81
  55. package/src/rust/components/column_selector/filter_column.rs +5 -15
  56. package/src/rust/components/column_selector/inactive_column.rs +3 -8
  57. package/src/rust/components/column_selector/sort_column.rs +3 -6
  58. package/src/rust/components/column_selector.rs +5 -11
  59. package/src/rust/components/containers/split_panel.rs +175 -62
  60. package/src/rust/components/context_menu.rs +130 -0
  61. package/src/rust/components/form/debug.rs +3 -0
  62. package/src/rust/components/global_filter_bar.rs +76 -0
  63. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  64. package/src/rust/components/main_panel/msg.rs +62 -0
  65. package/src/rust/components/main_panel/presize.rs +220 -0
  66. package/src/rust/components/main_panel/reconcile.rs +224 -0
  67. package/src/rust/components/main_panel/render.rs +385 -0
  68. package/src/rust/components/main_panel/update.rs +184 -0
  69. package/src/rust/components/main_panel.rs +311 -71
  70. package/src/rust/components/mod.rs +4 -0
  71. package/src/rust/components/panel_menu.rs +486 -0
  72. package/src/rust/components/panel_tab.rs +505 -0
  73. package/src/rust/components/plugin_tab.rs +1 -1
  74. package/src/rust/components/settings_panel.rs +13 -5
  75. package/src/rust/components/status_bar.rs +110 -178
  76. package/src/rust/components/status_indicator.rs +6 -9
  77. package/src/rust/components/style/mod.rs +5 -0
  78. package/src/rust/components/style/style_provider.rs +2 -0
  79. package/src/rust/components/viewer/filters.rs +133 -0
  80. package/src/rust/components/viewer/msg.rs +180 -0
  81. package/src/rust/components/viewer/panels.rs +326 -0
  82. package/src/rust/components/viewer/render.rs +243 -0
  83. package/src/rust/components/viewer/settings.rs +402 -0
  84. package/src/rust/components/viewer/snapshots.rs +134 -0
  85. package/src/rust/components/viewer/wiring.rs +505 -0
  86. package/src/rust/components/viewer.rs +243 -756
  87. package/src/rust/config/export_method.rs +1 -1
  88. package/src/rust/config/mod.rs +4 -0
  89. package/src/rust/config/options.rs +71 -0
  90. package/src/rust/config/viewer_config.rs +24 -2
  91. package/src/rust/config/workspace_config.rs +92 -0
  92. package/src/rust/custom_elements/copy_dropdown.rs +3 -1
  93. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  94. package/src/rust/custom_elements/export_dropdown.rs +3 -1
  95. package/src/rust/custom_elements/viewer.rs +1015 -286
  96. package/src/rust/custom_events.rs +212 -61
  97. package/src/rust/js/mod.rs +2 -0
  98. package/src/rust/js/plugin.rs +297 -10
  99. package/src/rust/js/regular_layout.rs +258 -0
  100. package/src/rust/lib.rs +20 -1
  101. package/src/rust/presentation.rs +66 -33
  102. package/src/rust/queries/get_viewer_config.rs +29 -11
  103. package/src/rust/queries/plugin_column_styles.rs +2 -2
  104. package/src/rust/renderer/activate.rs +41 -14
  105. package/src/rust/renderer/dispatch.rs +436 -0
  106. package/src/rust/renderer/plugin_config.rs +495 -0
  107. package/src/rust/renderer/registry.rs +18 -6
  108. package/src/rust/renderer/render_timer.rs +29 -10
  109. package/src/rust/renderer.rs +350 -837
  110. package/src/rust/session/column_defaults_update.rs +26 -8
  111. package/src/rust/session/view_subscription.rs +30 -15
  112. package/src/rust/session.rs +753 -264
  113. package/src/rust/tasks/apply_global_filters.rs +89 -0
  114. package/src/rust/tasks/auto_pause.rs +171 -0
  115. package/src/rust/tasks/copy_export.rs +29 -19
  116. package/src/rust/tasks/create_panel.rs +243 -0
  117. package/src/rust/tasks/dismiss_render_warning.rs +3 -1
  118. package/src/rust/tasks/edit_expression.rs +4 -4
  119. package/src/rust/tasks/eject.rs +64 -23
  120. package/src/rust/tasks/mod.rs +28 -4
  121. package/src/rust/tasks/pipeline.rs +411 -0
  122. package/src/rust/tasks/presize_panels.rs +324 -0
  123. package/src/rust/tasks/reset_all.rs +22 -12
  124. package/src/rust/tasks/resize_observer.rs +124 -87
  125. package/src/rust/tasks/restore_and_render.rs +80 -86
  126. package/src/rust/tasks/restore_panel.rs +145 -0
  127. package/src/rust/tasks/send_column_config.rs +5 -2
  128. package/src/rust/tasks/send_plugin_config.rs +5 -2
  129. package/src/rust/tasks/set_edit_mode.rs +59 -0
  130. package/src/rust/tasks/sync_update_panels.rs +152 -0
  131. package/src/rust/tasks/table_lifecycle.rs +158 -0
  132. package/src/rust/tasks/update_theme.rs +56 -12
  133. package/src/rust/utils/completion.rs +56 -0
  134. package/src/rust/utils/custom_element.rs +5 -0
  135. package/src/rust/utils/debounce.rs +130 -7
  136. package/src/rust/utils/mod.rs +6 -0
  137. package/src/rust/utils/pending_effects.rs +62 -0
  138. package/src/rust/utils/spawn.rs +33 -0
  139. package/src/rust/utils/weak_scope.rs +0 -10
  140. package/src/rust/workspace.rs +846 -0
  141. package/src/svg/active-indicator.svg +3 -0
  142. package/src/themes/botanical.css +5 -2
  143. package/src/themes/defaults.css +54 -2
  144. package/src/themes/dracula.css +6 -3
  145. package/src/themes/gruvbox-dark.css +5 -2
  146. package/src/themes/gruvbox.css +5 -2
  147. package/src/themes/icons.css +4 -2
  148. package/src/themes/monokai.css +5 -2
  149. package/src/themes/phosphor.css +5 -2
  150. package/src/themes/pro-dark.css +5 -2
  151. package/src/themes/pro.css +2 -1
  152. package/src/themes/solarized-dark.css +5 -2
  153. package/src/themes/solarized.css +5 -2
  154. package/src/themes/vaporwave.css +5 -2
  155. package/src/ts/bootstrap.ts +8 -7
  156. package/src/ts/extensions.ts +30 -2
  157. package/src/ts/perspective-viewer.ts +10 -0
  158. package/src/ts/plugin.ts +72 -5
  159. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  160. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  161. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  162. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  163. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  164. package/src/ts/ts-rs/Layout.ts +9 -0
  165. package/src/ts/ts-rs/Orientation.ts +6 -0
  166. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  167. package/src/ts/ts-rs/PanelViewerConfig.ts +15 -0
  168. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  169. package/src/ts/ts-rs/TabLayout.ts +11 -0
  170. package/src/ts/ts-rs/ViewerConfig.ts +6 -2
  171. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  172. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  173. package/src/rust/tasks/intersection_observer.rs +0 -98
  174. package/src/rust/tasks/update_and_render.rs +0 -110
  175. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline0.js → perspective-viewer-daeef4ceb066e6c7/inline1.js} +0 -0
  176. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline1.js → perspective-viewer-daeef4ceb066e6c7/inline2.js} +0 -0
  177. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline2.js → perspective-viewer-daeef4ceb066e6c7/inline3.js} +0 -0
  178. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline4.js → perspective-viewer-daeef4ceb066e6c7/inline5.js} +0 -0
@@ -10,35 +10,76 @@
10
10
  // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
+ use futures::future::join_all;
13
14
  use perspective_client::clone;
15
+ use perspective_js::utils::{ApiError, ApiResult};
14
16
  use yew::Component;
15
17
 
16
18
  use crate::ApiFuture;
17
- use crate::renderer::Renderer;
18
19
  use crate::root::Root;
19
- use crate::session::{ResetOptions, Session, TableIntermediateState};
20
+ use crate::session::{ResetOptions, TableIntermediateState};
21
+ use crate::workspace::Workspace;
20
22
 
21
- /// Tear down the entire viewer: emit `table_unloaded`, lock the renderer
22
- /// while it's destroyed, drop the Yew root, and reset the session to its
23
- /// ejected state.
24
- pub fn delete_all<T: Component>(
25
- session: &Session,
26
- renderer: &Renderer,
27
- root: &Root<T>,
28
- ) -> ApiFuture<()> {
29
- session.table_unloaded.emit(false);
30
- clone!(session, renderer, root);
31
- ApiFuture::new(renderer.clone().with_lock(async move {
32
- renderer.delete()?;
33
- root.borrow_mut().take().ok_or("Already deleted")?.destroy();
34
- session
35
- .reset(ResetOptions {
36
- config: true,
37
- expressions: true,
38
- table: Some(TableIntermediateState::Ejected),
39
- ..ResetOptions::default()
23
+ /// Tear down the entire viewer: dispose EVERY panel's engines (emit
24
+ /// `table_unloaded`, destroy the renderer under its own draw lock, eject the
25
+ /// session), then drop the Yew root once. Fans out over all panels — not just
26
+ /// the seed — so panels added via `addPanel` or a whole-element `restore` don't
27
+ /// leak their `View`/`Table` + plugin when the element is deleted or ejected.
28
+ pub fn delete_all<T: Component>(workspace: &Workspace, root: &Root<T>) -> ApiFuture<()> {
29
+ clone!(workspace, root);
30
+ ApiFuture::new(async move {
31
+ let panels = workspace
32
+ .take_reserved()
33
+ .into_iter()
34
+ .chain(
35
+ workspace
36
+ .panel_ids()
37
+ .into_iter()
38
+ .filter_map(|id| workspace.panel(&id)),
39
+ )
40
+ .collect::<Vec<_>>();
41
+
42
+ // Each panel owns its own `Renderer`/draw lock, so dispose them
43
+ // concurrently. `renderer.delete()` must hold the draw lock (it runs
44
+ // `plugin.delete()` synchronously), hence the per-panel `with_lock`.
45
+ // BEST-EFFORT teardown: attempt EVERY panel and ALWAYS destroy the
46
+ // Yew root, then report the first error. Aborting on the first
47
+ // failed panel (the previous behavior) leaked the root and every
48
+ // remaining panel's `View`/plugin.
49
+ let results = join_all(panels.iter().map(|panel| {
50
+ clone!(panel.session, panel.renderer);
51
+ session.table_unloaded.emit(false);
52
+ let was_errored = session.is_errored();
53
+ renderer.clone().with_lock(async move {
54
+ renderer.delete()?;
55
+ let reset = session
56
+ .reset(ResetOptions {
57
+ config: true,
58
+ expressions: true,
59
+ table: Some(TableIntermediateState::Ejected),
60
+ ..ResetOptions::default()
61
+ })
62
+ .await;
63
+
64
+ match reset {
65
+ Err(_) if was_errored => Ok(()),
66
+ other => other,
67
+ }
40
68
  })
41
- .await?;
69
+ }))
70
+ .await;
71
+
72
+ // Drop the Yew root once, after every panel's teardown was
73
+ // attempted — UNCONDITIONALLY, before error propagation, so a failed
74
+ // panel can't leak it.
75
+ let root_result = root
76
+ .borrow_mut()
77
+ .take()
78
+ .ok_or_else(|| ApiError::from("Already deleted"))
79
+ .map(|x| x.destroy());
80
+
81
+ results.into_iter().collect::<ApiResult<Vec<_>>>()?;
82
+ root_result?;
42
83
  Ok(())
43
- }))
84
+ })
44
85
  }
@@ -21,30 +21,54 @@
21
21
  //! [`Renderer`]: crate::renderer::Renderer
22
22
  //! [`Presentation`]: crate::presentation::Presentation
23
23
 
24
+ mod apply_global_filters;
25
+ mod auto_pause;
24
26
  mod copy_export;
27
+ mod create_panel;
25
28
  mod dismiss_render_warning;
26
29
  mod edit_expression;
27
30
  mod eject;
28
- mod intersection_observer;
31
+ mod pipeline;
32
+ mod presize_panels;
29
33
  mod reset_all;
30
34
  mod resize_observer;
31
35
  mod restore_and_render;
36
+ mod restore_panel;
32
37
  mod send_column_config;
33
38
  mod send_plugin_config;
34
- mod update_and_render;
39
+ mod set_edit_mode;
40
+ mod sync_update_panels;
41
+ mod table_lifecycle;
35
42
  mod update_theme;
36
43
  mod validate_expression;
37
44
 
45
+ /// How long staged work (a hidden first draw, a presize sweep) may withhold
46
+ /// a layout transition before it is released anyway — the progressive-reveal
47
+ /// fallback for slow (e.g. remote) tables.
48
+ pub(crate) const STAGING_DEADLINE_MS: i32 = 500;
49
+
50
+ /// Fallback panel chrome `(width, height)` px (margin + border + titlebar)
51
+ /// when no frame is available to measure live (see
52
+ /// [`presize_panels::plugin_chrome`]).
53
+ pub(crate) const CHROME_FALLBACK: (f64, f64) = (8.0, 33.0);
54
+
55
+ pub use self::apply_global_filters::*;
56
+ pub use self::auto_pause::*;
38
57
  pub use self::copy_export::*;
58
+ pub(crate) use self::create_panel::*;
39
59
  pub use self::dismiss_render_warning::*;
40
60
  pub use self::edit_expression::*;
41
61
  pub use self::eject::*;
42
- pub use self::intersection_observer::*;
62
+ pub use self::pipeline::*;
63
+ pub use self::presize_panels::*;
43
64
  pub use self::reset_all::*;
44
65
  pub use self::resize_observer::*;
45
66
  pub use self::restore_and_render::*;
67
+ pub(crate) use self::restore_panel::*;
46
68
  pub use self::send_column_config::*;
47
69
  pub use self::send_plugin_config::*;
48
- pub use self::update_and_render::*;
70
+ pub use self::set_edit_mode::*;
71
+ pub(crate) use self::sync_update_panels::*;
72
+ pub(crate) use self::table_lifecycle::*;
49
73
  pub use self::update_theme::*;
50
74
  pub use self::validate_expression::*;
@@ -0,0 +1,411 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ //! The render pipeline (see `SESSION_CONFIG_COHERENCE_PLAN.md`) — the ONE
14
+ //! review surface for how config-driven renders happen.
15
+ //!
16
+ //! Invariants enforced here and by the types this composes:
17
+ //! - **I1** config mutation is a synchronous commit
18
+ //! ([`Session::commit_view_config`]) — no async writer exists.
19
+ //! - **I2** every run consumes an immutable snapshot taken inside the draw
20
+ //! lock; async stages never re-read the live config (the `ValidatedSnapshot`
21
+ //! type-state token).
22
+ //! - **I3** runs serialize on the per-`Renderer` draw lock (the [`RenderGuard`]
23
+ //! witness — plugin dispatch without it does not compile) and every commit
24
+ //! schedules a run, so the final render always reflects the final commit.
25
+ //! - **I5** the run pins a [`RenderContext`] so plugin read-backs mid-render
26
+ //! answer from the run's snapshot, never live state.
27
+
28
+ use std::rc::Rc;
29
+
30
+ use futures::future::LocalBoxFuture;
31
+ use perspective_client::config::ViewConfigUpdate;
32
+ use perspective_client::{View, clone};
33
+ use perspective_js::utils::*;
34
+ use wasm_bindgen::JsValue;
35
+ use yew::prelude::*;
36
+
37
+ use crate::config::{ColumnConfigUpdate, PluginConfigUpdate};
38
+ use crate::presentation::Presentation;
39
+ use crate::renderer::{RenderContext, Renderer};
40
+ use crate::session::{BindDisposition, Session};
41
+ use crate::utils::RenderGuard;
42
+
43
+ /// Snapshot → validate → bind → cache + pin the [`RenderContext`]. The core
44
+ /// of every config-driven run. Returns HOW the bind reconciled (the
45
+ /// [`BindDisposition`], whose `Rebuilt` arm alone carries the `plugin.draw`
46
+ /// witness — see [`dispatch_bound`]) and the RAII context pin (dropped —
47
+ /// unpinning — when the caller's locked task ends).
48
+ pub async fn bind_snapshot(
49
+ guard: &RenderGuard,
50
+ session: &Session,
51
+ renderer: &Renderer,
52
+ ) -> ApiResult<(BindDisposition, Option<crate::renderer::ContextPin>)> {
53
+ // A `load()` is mid-classification (see [`Session::pending_load`]): DEFER
54
+ // rather than bind the incoming config against the still-bound outgoing
55
+ // table. Holds the last painted frame; the `load()` run performs the one
56
+ // reconciling bind once its payload resolves — the single visible
57
+ // transition, correct in whichever future (Table/Client) materializes.
58
+ if session.has_pending_load() {
59
+ return Ok((BindDisposition::Deferred, None));
60
+ }
61
+
62
+ let snap = session.snapshot(guard);
63
+ let validated = session.validate_snapshot(guard, snap).await?;
64
+ let disposition = session.bind_view(guard, validated).await?;
65
+ let pin = if let Some(view) = disposition.view() {
66
+ let ctx = Rc::new(build_render_context(session, renderer, view)?);
67
+ renderer.set_cached_context(ctx.clone());
68
+ Some(renderer.pin_context(guard, ctx))
69
+ } else {
70
+ None
71
+ };
72
+
73
+ Ok((disposition, pin))
74
+ }
75
+
76
+ /// Who initiated a render run — threaded EXPLICITLY from the closed set of
77
+ /// `#[wasm_bindgen]` element entry points, never inferred from lock state
78
+ /// or timing (a temporal "is this nested?" heuristic would misclassify
79
+ /// legitimate concurrent public calls).
80
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
81
+ pub enum RunOrigin {
82
+ /// A public element API entry (`restore`, `restorePanel`, `reset`,
83
+ /// `load`, …): an explicit external request, so a run that reconciles
84
+ /// `Unchanged` with no state delta STILL repaints — the documented
85
+ /// no-op-restore refresh affordance (`update` source 6, "the user
86
+ /// asked" — `PLUGIN_DRAW_INVARIANT_PLAN.md` amendment).
87
+ Public,
88
+ /// A host-initiated run (UI commits, unpause resumes, subscriptions):
89
+ /// a reconciled no-op dispatches nothing.
90
+ Internal,
91
+ }
92
+
93
+ /// The ONE decision table mapping a run's [`BindDisposition`] to its plugin
94
+ /// dispatch (`PLUGIN_DRAW_INVARIANT_PLAN.md`, as amended 2026-07-16):
95
+ /// `plugin.draw` fires iff there is a NEW `View` — a REBUILD, or a
96
+ /// freshly-selected plugin's first paint of the bound `View`
97
+ /// (`promote_first_paint`). `plugin.update` fires iff the `View` is
98
+ /// unchanged but a plugin-visible SOURCE changed: the `Adopted`
99
+ /// config delta, a genuinely changed plugin/columns bucket this run
100
+ /// delivered via `plugin.restore` (`plugin_state_changed`), or an explicit
101
+ /// public API request ([`RunOrigin::Public`]). An INTERNAL run that
102
+ /// reconciled `Unchanged` with no state delta dispatches NOTHING — no
103
+ /// defensive repaints (this is what makes an inert plugin echo, e.g. the
104
+ /// datagrid's `toggle_edit_mode` `restorePanel`, render-silent). Deferred
105
+ /// binds dispatch nothing.
106
+ pub async fn dispatch_bound(
107
+ guard: &RenderGuard,
108
+ renderer: &Renderer,
109
+ disposition: BindDisposition,
110
+ plugin_state_changed: bool,
111
+ origin: RunOrigin,
112
+ ) -> ApiResult<()> {
113
+ match disposition {
114
+ BindDisposition::Rebuilt(fresh) => renderer.draw_fresh(guard, fresh).await,
115
+ BindDisposition::Adopted(view) => match renderer.promote_first_paint(&view) {
116
+ Some(fresh) => renderer.draw_fresh(guard, fresh).await,
117
+ // The adoption's effective-config delta IS the source.
118
+ None => renderer.update_bound(guard, &view).await,
119
+ },
120
+ BindDisposition::Unchanged(view) => match renderer.promote_first_paint(&view) {
121
+ Some(fresh) => renderer.draw_fresh(guard, fresh).await,
122
+ None if plugin_state_changed || origin == RunOrigin::Public => {
123
+ renderer.update_bound(guard, &view).await
124
+ },
125
+ None => {
126
+ tracing::debug!("Render skipped, reconciled no-op (internal)");
127
+ Ok(())
128
+ },
129
+ },
130
+ BindDisposition::Deferred => {
131
+ tracing::debug!("Render skipped, no `View` attached");
132
+ Ok(())
133
+ },
134
+ }
135
+ }
136
+
137
+ /// Assemble the plugin-visible state bundle for the just-bound `View` —
138
+ /// every value a plugin may read back during its render, all belonging to
139
+ /// the same snapshot (I5).
140
+ fn build_render_context(
141
+ session: &Session,
142
+ renderer: &Renderer,
143
+ view: &View,
144
+ ) -> ApiResult<RenderContext> {
145
+ Ok(RenderContext {
146
+ view_config: session
147
+ .get_rendered_view_config()
148
+ .ok_or("No bound `View`")?,
149
+ view: view.clone(),
150
+ table: session.get_table().ok_or("No `Table` set")?,
151
+ client: session.get_client().ok_or("No `Client` set")?,
152
+ edit_port: session.metadata().get_edit_port(),
153
+ theme: renderer.theme(),
154
+ })
155
+ }
156
+
157
+ /// Commit `update` (sync, may `Err` before any state changes — I4) and
158
+ /// schedule a locked run. The single entry point for every "change the
159
+ /// config and draw" action; returns the run's future (I6: public mutators
160
+ /// resolve at render completion).
161
+ pub fn apply_and_render(
162
+ session: &Session,
163
+ renderer: &Renderer,
164
+ update: ViewConfigUpdate,
165
+ ) -> ApiResult<ApiFuture<()>> {
166
+ update_plugin_and_render(session, renderer, update, None)
167
+ }
168
+
169
+ /// [`apply_and_render`] plus a plugin selection (resolved via
170
+ /// [`Renderer::resolve_plugin_update`]). The plugin swap is committed inside
171
+ /// the locked run, atomically with the view rebind — never staged on the
172
+ /// `Renderer` where a concurrent draw could observe it.
173
+ pub fn update_plugin_and_render(
174
+ session: &Session,
175
+ renderer: &Renderer,
176
+ update: ViewConfigUpdate,
177
+ plugin_idx: Option<usize>,
178
+ ) -> ApiResult<ApiFuture<()>> {
179
+ session.commit_view_config(update)?;
180
+ // Spinner accounting (RAII): created only when the commit succeeded,
181
+ // moved into the run future, settled on every exit path by `Drop`.
182
+ let run_token = session.begin_config_run();
183
+ clone!(session, renderer);
184
+ Ok(ApiFuture::new(async move {
185
+ let _run_token = run_token;
186
+ render_run(session, renderer, plugin_idx).await
187
+ }))
188
+ }
189
+
190
+ /// Re-render from the current commit without applying an update.
191
+ pub fn just_render(session: &Session, renderer: &Renderer) -> ApiResult<ApiFuture<()>> {
192
+ clone!(session, renderer);
193
+ Ok(ApiFuture::new(async move {
194
+ render_run(session, renderer, None).await
195
+ }))
196
+ }
197
+
198
+ /// Everything that varies between locked render runs, consumed by
199
+ /// [`locked_run`] — the ONE lock-body composition. `Default` is the
200
+ /// host-internal UI-commit run: `Internal` origin, no plugin swap, no
201
+ /// pre-bind task, no bucket updates, no dispatch visibility gate.
202
+ pub(crate) struct RunSpec {
203
+ /// Who initiated this run (see [`RunOrigin`]).
204
+ pub origin: RunOrigin,
205
+
206
+ /// A plugin swap resolved by [`Renderer::resolve_plugin_update`],
207
+ /// committed inside the run atomically with the rebind.
208
+ pub plugin_idx: Option<usize>,
209
+
210
+ /// Plugin-level bucket update, applied inside the run after the bind
211
+ /// (so strip-on-write sees fresh schemas).
212
+ pub plugin_config: PluginConfigUpdate,
213
+
214
+ /// Per-column bucket update; same timing as `plugin_config`.
215
+ pub columns_config: ColumnConfigUpdate,
216
+
217
+ /// Pre-bind hook awaited inside the lock (table binding, resets) —
218
+ /// BEFORE the error guard, so a task that recovers the session (e.g.
219
+ /// `restorePanel`'s errored-recovery reset) unblocks its own run.
220
+ pub task: Option<LocalBoxFuture<'static, ApiResult<()>>>,
221
+
222
+ /// When set, plugin dispatch is skipped while the host element is not
223
+ /// visible (`Presentation::is_visible`) — the `restore` family's gate
224
+ /// (the eventual auto-pause resume rebuilds and redraws). `None`
225
+ /// dispatches unconditionally (UI-commit runs, whose callers gate on
226
+ /// drawn/loaded state themselves).
227
+ pub presentation: Option<Presentation>,
228
+ }
229
+
230
+ impl Default for RunSpec {
231
+ fn default() -> Self {
232
+ Self {
233
+ origin: RunOrigin::Internal,
234
+ plugin_idx: None,
235
+ plugin_config: PluginConfigUpdate::Missing,
236
+ columns_config: ColumnConfigUpdate::Missing,
237
+ task: None,
238
+ presentation: None,
239
+ }
240
+ }
241
+ }
242
+
243
+ /// One locked, witnessed, snapshot-consuming render run — the SINGLE lock
244
+ /// body shared by every config-driven run (`apply_and_render` &co. via
245
+ /// [`render_run`]'s tail, the `restore` family via
246
+ /// [`super::restore_and_render`]): eager mount → pre-bind `task` →
247
+ /// plugin-swap commit → theme stamp → error guard → [`bind_snapshot`]
248
+ /// (gated on a bound `Table`, else `Deferred` — the config is already
249
+ /// committed, so the eventual `load()` run binds from it) → bucket updates
250
+ /// + materialized `plugin.restore` → [`dispatch_bound`].
251
+ ///
252
+ /// A pre-existing session error skips the run for `Internal` origins and
253
+ /// fails it for `Public` ones (the caller asked and must hear the failure —
254
+ /// I6). Returns the RAW run result; error-reporting policy (`set_run_error`
255
+ /// vs. propagate) belongs to the caller.
256
+ pub(crate) async fn locked_run(
257
+ session: &Session,
258
+ renderer: &Renderer,
259
+ spec: RunSpec,
260
+ ) -> ApiResult<()> {
261
+ clone!(session, renderer);
262
+ renderer
263
+ .clone()
264
+ .render_task(|guard| async move {
265
+ renderer.mount_active_plugin()?;
266
+ if let Some(task) = spec.task {
267
+ task.await?;
268
+ }
269
+
270
+ let plugin_swapped = renderer.commit_plugin(spec.plugin_idx)?;
271
+ let plugin = renderer.active_plugin().ok_or("No Plugin")?;
272
+ renderer.stamp_theme(Some(&plugin));
273
+ if let Some(error) = session.get_error() {
274
+ return match spec.origin {
275
+ RunOrigin::Public => Err(error),
276
+ RunOrigin::Internal => Ok(()),
277
+ };
278
+ }
279
+
280
+ let (disposition, _pin) = if session.get_table().is_some() {
281
+ bind_snapshot(&guard, &session, &renderer).await?
282
+ } else {
283
+ (BindDisposition::Deferred, None)
284
+ };
285
+
286
+ let view_config_snapshot = session.get_view_config().clone();
287
+ let plugin_config_changed =
288
+ renderer.update_plugin_config(&view_config_snapshot, spec.plugin_config);
289
+ let columns_config_changed = renderer.update_columns_configs(
290
+ &view_config_snapshot,
291
+ &session,
292
+ spec.columns_config,
293
+ );
294
+
295
+ let changed = plugin_config_changed || columns_config_changed;
296
+ if changed || plugin_swapped {
297
+ let plugin_config_snapshot = renderer.get_plugin_config();
298
+ let plugin_update =
299
+ JsValue::from_serde_ext(&plugin_config_snapshot).unwrap_or(JsValue::NULL);
300
+ let columns_config = renderer
301
+ .all_columns_configs_materialized(&view_config_snapshot, &session)
302
+ .await;
303
+ plugin.restore(&plugin_update, Some(&columns_config))?;
304
+ if plugin_config_changed {
305
+ renderer.plugin_config_changed.emit(plugin_config_snapshot);
306
+ }
307
+ }
308
+
309
+ if spec
310
+ .presentation
311
+ .as_ref()
312
+ .map(|p| p.is_visible())
313
+ .unwrap_or(true)
314
+ {
315
+ dispatch_bound(&guard, &renderer, disposition, changed, spec.origin).await?;
316
+ }
317
+
318
+ Ok(())
319
+ })
320
+ .await
321
+ }
322
+
323
+ /// [`locked_run`]'s host-internal tail: a failed RUN sets error state (with
324
+ /// the reset-reconnect affordance); the committed config is NOT rolled back
325
+ /// (I4). Cancellation by a superseding run ("View already deleted") is not
326
+ /// a failure.
327
+ async fn render_run(
328
+ session: Session,
329
+ renderer: Renderer,
330
+ plugin_idx: Option<usize>,
331
+ ) -> ApiResult<()> {
332
+ let spec = RunSpec {
333
+ plugin_idx,
334
+ ..RunSpec::default()
335
+ };
336
+
337
+ match locked_run(&session, &renderer, spec)
338
+ .await
339
+ .ignore_view_delete()
340
+ {
341
+ Err(e) => session.set_run_error(e).await,
342
+ Ok(_) => Ok(()),
343
+ }
344
+ }
345
+
346
+ /// One TRANSACTIONAL activation repaint (see the I5 audit gap in
347
+ /// `SESSION_CONFIG_COHERENCE_PLAN.md` §4): under the panel's draw lock,
348
+ /// stamp the `active` class + theme and `plugin.resize()` in ONE dispatch,
349
+ /// so the activation-dependent chrome (e.g. the datagrid's edit
350
+ /// column-header row, decided by its style listener reading that class) and
351
+ /// the class that styles it land in a single paint commit, never split.
352
+ ///
353
+ /// `resize`, NOT `draw`/`update`: activation creates no new `View` and
354
+ /// changes no data (`plugin.draw` ⇔ new `View` —
355
+ /// `PLUGIN_DRAW_INVARIANT_PLAN.md`), and a full dispatch on charts is a
356
+ /// fetch + multi-blit repaint — the stacked-tab two-stage regression, paid
357
+ /// by BOTH sides of every tab switch. `resize` is each plugin's cheap
358
+ /// repaint-from-retained-state, and both built-ins skip it while hidden, so
359
+ /// the OUTGOING (unslotted) panel's nudge costs nothing. Deliberately NOT
360
+ /// debounced/throttled: activation is a one-shot interaction, not a data
361
+ /// stream, and the throttle timer is a task boundary the browser paints
362
+ /// across.
363
+ ///
364
+ /// EXCEPTION: a panel whose `table_updated` redraws were dropped while it
365
+ /// was hidden ([`Renderer::take_data_stale`]) HAS changed data — its
366
+ /// retained frame is stale, so this activation dispatches one full
367
+ /// `plugin.update` (same `View`, fresh data; `draw` stays reserved for new
368
+ /// `View`s) instead of the chrome-only repaint. `draw_view` stamps the
369
+ /// activation class + theme itself, so the atomicity contract above holds
370
+ /// on this branch too.
371
+ pub async fn activation_render(session: Session, renderer: Renderer) -> ApiResult<()> {
372
+ let result = {
373
+ clone!(session, renderer);
374
+ renderer
375
+ .clone()
376
+ .render_task(|guard| async move {
377
+ match session.get_view() {
378
+ Some(view) => {
379
+ let _pin = renderer
380
+ .cached_context()
381
+ .map(|ctx| renderer.pin_context(&guard, ctx));
382
+ if renderer.take_data_stale() {
383
+ renderer.update_bound(&guard, &view).await
384
+ } else {
385
+ renderer.activation_repaint(&guard).await
386
+ }
387
+ },
388
+ None => Ok(()),
389
+ }
390
+ })
391
+ .await
392
+ };
393
+
394
+ result.ignore_view_delete().map(|_| ())
395
+ }
396
+
397
+ /// Create a [`Callback`] that resizes from the current `View` and `Plugin`,
398
+ /// or runs a full render when the plugin has never drawn.
399
+ pub fn resize_callback(session: &Session, renderer: &Renderer) -> Callback<()> {
400
+ clone!(session, renderer);
401
+ Callback::from(move |_| {
402
+ clone!(renderer, session);
403
+ crate::utils::spawn_owned("resize-callback", async move {
404
+ if !renderer.is_plugin_activated()? {
405
+ just_render(&session, &renderer)?.await
406
+ } else {
407
+ renderer.resize().await
408
+ }
409
+ });
410
+ })
411
+ }