@perspective-dev/viewer 4.5.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/dist/cdn/perspective-viewer.js +20 -1
  2. package/dist/cdn/perspective-viewer.js.map +4 -4
  3. package/dist/css/botanical.css +1 -1
  4. package/dist/css/dracula.css +1 -1
  5. package/dist/css/gruvbox-dark.css +1 -1
  6. package/dist/css/gruvbox.css +1 -1
  7. package/dist/css/icons.css +1 -1
  8. package/dist/css/intl/de.css +1 -1
  9. package/dist/css/intl/es.css +1 -1
  10. package/dist/css/intl/fr.css +1 -1
  11. package/dist/css/intl/ja.css +1 -1
  12. package/dist/css/intl/pt.css +1 -1
  13. package/dist/css/intl/zh.css +1 -1
  14. package/dist/css/intl.css +1 -1
  15. package/dist/css/monokai.css +1 -1
  16. package/dist/css/phosphor.css +1 -1
  17. package/dist/css/pro-dark.css +1 -1
  18. package/dist/css/pro.css +1 -1
  19. package/dist/css/solarized-dark.css +1 -1
  20. package/dist/css/solarized.css +1 -1
  21. package/dist/css/themes.css +1 -1
  22. package/dist/css/vaporwave.css +1 -1
  23. package/dist/esm/extensions.d.ts +22 -3
  24. package/dist/esm/perspective-viewer.d.ts +9 -0
  25. package/dist/esm/perspective-viewer.inline.js +20 -1
  26. package/dist/esm/perspective-viewer.inline.js.map +4 -4
  27. package/dist/esm/perspective-viewer.js +20 -1
  28. package/dist/esm/perspective-viewer.js.map +4 -4
  29. package/dist/esm/plugin.d.ts +71 -5
  30. package/dist/esm/ts-rs/ClientOptions.d.ts +7 -0
  31. package/dist/esm/ts-rs/ExportMethod.d.ts +1 -0
  32. package/dist/esm/ts-rs/ExportOptions.d.ts +9 -0
  33. package/dist/esm/ts-rs/GetClientOptions.d.ts +8 -0
  34. package/dist/esm/ts-rs/GetTableOptions.d.ts +8 -0
  35. package/dist/esm/ts-rs/Layout.d.ts +11 -0
  36. package/dist/esm/ts-rs/Orientation.d.ts +4 -0
  37. package/dist/esm/ts-rs/PanelOptions.d.ts +7 -0
  38. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +41 -0
  39. package/dist/esm/ts-rs/SplitLayout.d.ts +7 -0
  40. package/dist/esm/ts-rs/TabLayout.d.ts +10 -0
  41. package/dist/esm/ts-rs/ViewerConfig.d.ts +8 -2
  42. package/dist/esm/ts-rs/ViewerConfigUpdate.d.ts +9 -0
  43. package/dist/esm/ts-rs/WindowAggregate.d.ts +1 -0
  44. package/dist/esm/ts-rs/WindowSort.d.ts +7 -0
  45. package/dist/esm/ts-rs/WindowSortDir.d.ts +6 -0
  46. package/dist/esm/ts-rs/WindowSpec.d.ts +46 -0
  47. package/dist/esm/ts-rs/Windows.d.ts +10 -0
  48. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +40 -0
  49. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +28 -0
  50. package/dist/wasm/perspective-viewer.d.ts +299 -78
  51. package/dist/wasm/perspective-viewer.js +544 -186
  52. package/dist/wasm/perspective-viewer.wasm +0 -0
  53. package/dist/wasm/perspective-viewer.wasm.d.ts +34 -26
  54. package/dist/wasm/snippets/perspective-viewer-8a8c7d5d696b43ae/inline0.js +6 -0
  55. package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline3.js → perspective-viewer-8a8c7d5d696b43ae/inline4.js} +5 -0
  56. package/package.json +3 -2
  57. package/src/css/_context_menu.css +14 -0
  58. package/src/css/column-selector.css +1 -0
  59. package/src/css/column-settings-panel.css +68 -3
  60. package/src/css/column-style.css +4 -3
  61. package/src/css/config-selector.css +22 -1
  62. package/src/css/containers/context-menu.css +107 -0
  63. package/src/css/containers/tabs.css +2 -2
  64. package/src/css/panel-tab.css +193 -0
  65. package/src/css/plugin-selector.css +7 -10
  66. package/src/css/status-bar.css +75 -81
  67. package/src/css/viewer.css +188 -70
  68. package/src/rust/components/column_dropdown.rs +40 -30
  69. package/src/rust/components/column_selector/active_column.rs +28 -45
  70. package/src/rust/components/column_selector/aggregate_selector.rs +4 -7
  71. package/src/rust/components/column_selector/column_selector_column_row.rs +73 -0
  72. package/src/rust/components/column_selector/config_selector.rs +40 -82
  73. package/src/rust/components/column_selector/expr_edit_button.rs +7 -1
  74. package/src/rust/components/column_selector/filter_column.rs +5 -15
  75. package/src/rust/components/column_selector/inactive_column.rs +11 -9
  76. package/src/rust/components/column_selector/sort_column.rs +3 -6
  77. package/src/rust/components/column_selector.rs +25 -14
  78. package/src/rust/components/column_settings_sidebar/style_tab.rs +2 -1
  79. package/src/rust/components/column_settings_sidebar/window_tab.rs +34 -0
  80. package/src/rust/components/column_settings_sidebar.rs +163 -16
  81. package/src/rust/components/containers/dragdrop_list.rs +73 -3
  82. package/src/rust/components/containers/sidebar.rs +99 -22
  83. package/src/rust/components/containers/split_panel.rs +181 -62
  84. package/src/rust/components/context_menu.rs +130 -0
  85. package/src/rust/components/editable_header.rs +2 -0
  86. package/src/rust/components/form/debug.rs +3 -0
  87. package/src/rust/components/global_filter_bar.rs +76 -0
  88. package/src/rust/components/main_panel/frame_theme.rs +186 -0
  89. package/src/rust/components/main_panel/msg.rs +62 -0
  90. package/src/rust/components/main_panel/presize.rs +220 -0
  91. package/src/rust/components/main_panel/reconcile.rs +224 -0
  92. package/src/rust/components/main_panel/render.rs +385 -0
  93. package/src/rust/components/main_panel/update.rs +184 -0
  94. package/src/rust/components/main_panel.rs +316 -71
  95. package/src/rust/components/mod.rs +5 -0
  96. package/src/rust/components/panel_menu.rs +493 -0
  97. package/src/rust/components/panel_tab.rs +510 -0
  98. package/src/rust/components/plugin_tab.rs +1 -1
  99. package/src/rust/components/portal.rs +7 -0
  100. package/src/rust/components/settings_panel.rs +13 -5
  101. package/src/rust/components/status_bar.rs +110 -178
  102. package/src/rust/components/status_indicator.rs +6 -9
  103. package/src/rust/components/style/mod.rs +5 -0
  104. package/src/rust/components/style/style_provider.rs +2 -0
  105. package/src/rust/components/viewer/filters.rs +133 -0
  106. package/src/rust/components/viewer/msg.rs +182 -0
  107. package/src/rust/components/viewer/panels.rs +326 -0
  108. package/src/rust/components/viewer/render.rs +255 -0
  109. package/src/rust/components/viewer/settings.rs +434 -0
  110. package/src/rust/components/viewer/snapshots.rs +134 -0
  111. package/src/rust/components/viewer/wiring.rs +505 -0
  112. package/src/rust/components/viewer.rs +245 -756
  113. package/src/rust/components/window_editor.rs +1013 -0
  114. package/src/rust/config/export_method.rs +1 -1
  115. package/src/rust/config/mod.rs +4 -0
  116. package/src/rust/config/options.rs +71 -0
  117. package/src/rust/config/viewer_config.rs +24 -2
  118. package/src/rust/config/workspace_config.rs +92 -0
  119. package/src/rust/custom_elements/copy_dropdown.rs +3 -1
  120. package/src/rust/custom_elements/debug_plugin.rs +20 -1
  121. package/src/rust/custom_elements/export_dropdown.rs +3 -1
  122. package/src/rust/custom_elements/viewer.rs +1015 -286
  123. package/src/rust/custom_events.rs +212 -61
  124. package/src/rust/js/mod.rs +2 -0
  125. package/src/rust/js/plugin.rs +297 -10
  126. package/src/rust/js/regular_layout.rs +258 -0
  127. package/src/rust/lib.rs +24 -2
  128. package/src/rust/presentation/column_locator.rs +16 -1
  129. package/src/rust/presentation.rs +66 -33
  130. package/src/rust/queries/columns_iter_set.rs +7 -1
  131. package/src/rust/queries/get_viewer_config.rs +29 -11
  132. package/src/rust/queries/plugin_column_styles.rs +2 -2
  133. package/src/rust/renderer/activate.rs +41 -14
  134. package/src/rust/renderer/dispatch.rs +463 -0
  135. package/src/rust/renderer/plugin_config.rs +495 -0
  136. package/src/rust/renderer/registry.rs +18 -6
  137. package/src/rust/renderer/render_timer.rs +29 -10
  138. package/src/rust/renderer.rs +384 -838
  139. package/src/rust/session/column_defaults_update.rs +26 -8
  140. package/src/rust/session/drag_drop_update.rs +18 -0
  141. package/src/rust/session/metadata.rs +66 -9
  142. package/src/rust/session/replace_expression_update.rs +1 -0
  143. package/src/rust/session/view_subscription.rs +30 -15
  144. package/src/rust/session.rs +755 -264
  145. package/src/rust/tasks/apply_global_filters.rs +89 -0
  146. package/src/rust/tasks/auto_pause.rs +171 -0
  147. package/src/rust/tasks/copy_export.rs +29 -19
  148. package/src/rust/tasks/create_panel.rs +243 -0
  149. package/src/rust/tasks/dismiss_render_warning.rs +3 -1
  150. package/src/rust/tasks/edit_expression.rs +4 -4
  151. package/src/rust/tasks/edit_window.rs +155 -0
  152. package/src/rust/tasks/eject.rs +64 -23
  153. package/src/rust/tasks/mod.rs +30 -4
  154. package/src/rust/tasks/pipeline.rs +411 -0
  155. package/src/rust/tasks/presize_panels.rs +324 -0
  156. package/src/rust/tasks/reset_all.rs +22 -12
  157. package/src/rust/tasks/resize_observer.rs +124 -87
  158. package/src/rust/tasks/restore_and_render.rs +80 -86
  159. package/src/rust/tasks/restore_panel.rs +145 -0
  160. package/src/rust/tasks/send_column_config.rs +5 -2
  161. package/src/rust/tasks/send_plugin_config.rs +5 -2
  162. package/src/rust/tasks/set_edit_mode.rs +59 -0
  163. package/src/rust/tasks/sync_update_panels.rs +152 -0
  164. package/src/rust/tasks/table_lifecycle.rs +158 -0
  165. package/src/rust/tasks/update_theme.rs +56 -12
  166. package/src/rust/utils/browser/dragdrop.rs +12 -0
  167. package/src/rust/utils/browser/tests/debounce.rs +49 -0
  168. package/src/rust/utils/completion.rs +56 -0
  169. package/src/rust/utils/custom_element.rs +5 -0
  170. package/src/rust/utils/debounce.rs +189 -17
  171. package/src/rust/utils/mod.rs +6 -0
  172. package/src/rust/utils/pending_effects.rs +62 -0
  173. package/src/rust/utils/spawn.rs +33 -0
  174. package/src/rust/utils/weak_scope.rs +0 -10
  175. package/src/rust/workspace.rs +846 -0
  176. package/src/svg/active-indicator.svg +3 -0
  177. package/src/svg/pin-icon.svg +5 -0
  178. package/src/themes/botanical.css +5 -2
  179. package/src/themes/defaults.css +54 -2
  180. package/src/themes/dracula.css +6 -3
  181. package/src/themes/gruvbox-dark.css +5 -2
  182. package/src/themes/gruvbox.css +5 -2
  183. package/src/themes/icons.css +5 -2
  184. package/src/themes/intl/de.css +7 -0
  185. package/src/themes/intl/es.css +7 -0
  186. package/src/themes/intl/fr.css +7 -0
  187. package/src/themes/intl/ja.css +7 -0
  188. package/src/themes/intl/pt.css +7 -0
  189. package/src/themes/intl/zh.css +7 -0
  190. package/src/themes/intl.css +10 -1
  191. package/src/themes/monokai.css +5 -2
  192. package/src/themes/phosphor.css +5 -2
  193. package/src/themes/pro-dark.css +5 -2
  194. package/src/themes/pro.css +2 -1
  195. package/src/themes/solarized-dark.css +5 -2
  196. package/src/themes/solarized.css +5 -2
  197. package/src/themes/vaporwave.css +5 -2
  198. package/src/ts/bootstrap.ts +8 -7
  199. package/src/ts/extensions.ts +30 -2
  200. package/src/ts/perspective-viewer.ts +10 -0
  201. package/src/ts/plugin.ts +72 -5
  202. package/src/ts/ts-rs/ClientOptions.ts +7 -0
  203. package/src/ts/ts-rs/ExportMethod.ts +3 -0
  204. package/src/ts/ts-rs/ExportOptions.ts +8 -0
  205. package/src/ts/ts-rs/GetClientOptions.ts +7 -0
  206. package/src/ts/ts-rs/GetTableOptions.ts +7 -0
  207. package/src/ts/ts-rs/Layout.ts +9 -0
  208. package/src/ts/ts-rs/Orientation.ts +6 -0
  209. package/src/ts/ts-rs/PanelOptions.ts +7 -0
  210. package/src/ts/ts-rs/PanelViewerConfig.ts +16 -0
  211. package/src/ts/ts-rs/SplitLayout.ts +5 -0
  212. package/src/ts/ts-rs/TabLayout.ts +11 -0
  213. package/src/ts/ts-rs/ViewerConfig.ts +7 -2
  214. package/src/ts/ts-rs/ViewerConfigUpdate.ts +9 -0
  215. package/src/ts/ts-rs/WindowAggregate.ts +3 -0
  216. package/src/ts/ts-rs/WindowSort.ts +9 -0
  217. package/src/ts/ts-rs/WindowSortDir.ts +8 -0
  218. package/src/ts/ts-rs/WindowSpec.ts +46 -0
  219. package/src/ts/ts-rs/Windows.ts +10 -0
  220. package/src/ts/ts-rs/WorkspaceConfig.ts +36 -0
  221. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +24 -0
  222. package/src/rust/tasks/intersection_observer.rs +0 -98
  223. package/src/rust/tasks/update_and_render.rs +0 -110
  224. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline0.js → perspective-viewer-8a8c7d5d696b43ae/inline1.js} +0 -0
  225. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline1.js → perspective-viewer-8a8c7d5d696b43ae/inline2.js} +0 -0
  226. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline2.js → perspective-viewer-8a8c7d5d696b43ae/inline3.js} +0 -0
  227. /package/dist/wasm/snippets/{perspective-viewer-93064d0d79855d97/inline4.js → perspective-viewer-8a8c7d5d696b43ae/inline5.js} +0 -0
@@ -11,14 +11,17 @@
11
11
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
12
 
13
13
  use perspective_client::clone;
14
- use web_sys::Element;
14
+ use wasm_bindgen::JsCast;
15
+ use wasm_bindgen::prelude::Closure;
16
+ use web_sys::HtmlElement;
15
17
  use yew::{
16
- Callback, Children, Html, Properties, function_component, html, use_effect_with, use_node_ref,
17
- use_state_eq,
18
+ Callback, Children, Html, Properties, function_component, html, use_effect_with, use_mut_ref,
19
+ use_node_ref,
18
20
  };
19
21
 
20
22
  use crate::components::containers::sidebar_close_button::SidebarCloseButton;
21
23
  use crate::components::editable_header::{EditableHeader, EditableHeaderProps};
24
+ use crate::js::{ResizeObserver, ResizeObserverEntry};
22
25
 
23
26
  #[derive(PartialEq, Clone, Properties)]
24
27
  pub struct SidebarProps {
@@ -31,6 +34,28 @@ pub struct SidebarProps {
31
34
  pub width_override: Option<i32>,
32
35
  pub selected_tab: Option<usize>,
33
36
  pub header_props: EditableHeaderProps,
37
+
38
+ /// Trap-door width shared across this sidebar's tabs: the lifted
39
+ /// running max of the widths this component reports through
40
+ /// `on_auto_width`. Held by the parent (ultimately
41
+ /// `PerspectiveViewer`'s geometry state, like the settings panel's
42
+ /// Query/Plugin/Debug trap-door) so it survives tab switches AND
43
+ /// sidebar re-mounts, and clears on divider reset.
44
+ #[prop_or_default]
45
+ pub auto_width: f64,
46
+
47
+ /// Fires with the sidebar's rendered width after each render; the
48
+ /// owner keeps the running max threaded back as `auto_width`.
49
+ #[prop_or_default]
50
+ pub on_auto_width: Callback<f64>,
51
+
52
+ /// Pinned state for the header's pin toggle; the button renders only
53
+ /// when `on_toggle_pin` is provided.
54
+ #[prop_or_default]
55
+ pub is_pinned: bool,
56
+
57
+ #[prop_or_default]
58
+ pub on_toggle_pin: Option<Callback<()>>,
34
59
  }
35
60
 
36
61
  /// Sidebars are designed to live in a
@@ -39,33 +64,85 @@ pub struct SidebarProps {
39
64
  pub fn Sidebar(p: &SidebarProps) -> Html {
40
65
  let id = &p.id_prefix;
41
66
  let noderef = use_node_ref();
42
- let auto_width = use_state_eq(|| 0f64);
43
-
44
- // this gets the last calculated width and ensures that
45
- // the auto-width element is at least that big.
46
- // this ensures the panel grows but does not shrink.
47
- use_effect_with(p.clone(), {
48
- clone!(noderef, auto_width);
49
- move |p| {
50
- if p.width_override.is_none() {
51
- let updated_width = noderef
52
- .cast::<Element>()
53
- .map(|el| el.get_bounding_client_rect().width())
54
- .unwrap_or_default();
55
- let new_auto_width = (*auto_width).max(updated_width);
56
- auto_width.set(new_auto_width);
57
- } else {
58
- auto_width.set(0f64);
67
+
68
+ // The trap-door reports the sidebar's rendered width to its owner via
69
+ // a `ResizeObserver` on the sidebar element, NOT a render effect:
70
+ // width changes are driven by DOM mutations anywhere in the tab
71
+ // subtree (e.g. the window editor staging a long column name into a
72
+ // slot), which need not re-render this component at all. The observer
73
+ // sees every one. `contentRect` (not the border box) is load-bearing:
74
+ // the sizer below is a content child, so ratcheting the border box
75
+ // would feed any sidebar padding back into unbounded growth. A manual
76
+ // divider drag (`width_override`) disables the ratchet until the
77
+ // divider resets.
78
+ let live_props = use_mut_ref(|| (Callback::<f64>::default(), None::<i32>));
79
+ *live_props.borrow_mut() = (p.on_auto_width.clone(), p.width_override);
80
+ use_effect_with((), {
81
+ clone!(noderef, live_props);
82
+ move |_| {
83
+ let closure =
84
+ Closure::<dyn FnMut(js_sys::Array)>::new(move |entries: js_sys::Array| {
85
+ let (on_auto_width, width_override) = live_props.borrow().clone();
86
+ if width_override.is_none() {
87
+ for entry in entries.iter() {
88
+ let entry: ResizeObserverEntry = entry.unchecked_into();
89
+ on_auto_width.emit(entry.content_rect().width());
90
+ }
91
+ }
92
+ });
93
+
94
+ let observer = ResizeObserver::new(closure.as_ref().unchecked_ref());
95
+ let elem = noderef.cast::<HtmlElement>();
96
+ if let Some(elem) = &elem {
97
+ observer.observe(elem);
98
+ }
99
+
100
+ move || {
101
+ if let Some(elem) = &elem {
102
+ observer.unobserve(elem);
103
+ }
104
+
105
+ drop(closure);
59
106
  }
60
107
  }
61
108
  });
62
109
 
63
- let width_style = format!("min-width: 200px; width: {}px", *auto_width);
110
+ let auto_width = if p.width_override.is_none() {
111
+ p.auto_width
112
+ } else {
113
+ 0.0
114
+ };
115
+
116
+ let width_style = format!("min-width: 200px; width: {}px", auto_width);
117
+ let pin_button = p.on_toggle_pin.as_ref().map(|cb| {
118
+ let onclick = {
119
+ let cb = cb.clone();
120
+ Callback::from(move |_: web_sys::MouseEvent| cb.emit(()))
121
+ };
122
+
123
+ let mut class = yew::classes!("sidebar_pin_button");
124
+ if p.is_pinned {
125
+ class.push("is-pinned");
126
+ }
127
+
128
+ html! {
129
+ <span
130
+ id={format!("{id}_pin_button")}
131
+ {class}
132
+ title={if p.is_pinned { "Unpin" } else { "Pin" }}
133
+ {onclick}
134
+ />
135
+ }
136
+ });
137
+
64
138
  html! {
65
139
  <>
66
140
  <SidebarCloseButton id={format!("{id}_close_button")} on_close_sidebar={&p.on_close} />
67
141
  <div class="sidebar_column" id={format!("{id}_sidebar")} ref={noderef}>
68
- <div class="sidebar_header"><EditableHeader ..p.header_props.clone() /></div>
142
+ <div class="sidebar_header">
143
+ <EditableHeader ..p.header_props.clone() />
144
+ { pin_button }
145
+ </div>
69
146
  <div class="sidebar_border" id={format!("{id}_border")} />
70
147
  { p.children.iter().collect::<Html>() }
71
148
  <div class="sidebar-auto-width" style={width_style} />
@@ -26,11 +26,17 @@ pub struct SplitPanelProps {
26
26
  #[prop_or_default]
27
27
  pub id: Option<String>,
28
28
 
29
+ /// Extra classes on the root element (ignored under `no_wrap`).
30
+ #[prop_or_default]
31
+ pub class: Classes,
32
+
29
33
  #[prop_or_default]
30
34
  pub orientation: Orientation,
31
35
 
32
- /// Whether to render `<></>` empty templates as empty child panels, or
33
- /// omit them entirely.
36
+ /// Whether to OMIT children that render nothing (an empty fragment, or
37
+ /// any nesting of empty fragments — see [`is_empty_html`]) instead of
38
+ /// rendering them as empty panes. A skipped pane takes its divider with
39
+ /// it.
34
40
  #[prop_or_default]
35
41
  pub skip_empty: bool,
36
42
 
@@ -53,6 +59,50 @@ pub struct SplitPanelProps {
53
59
 
54
60
  #[prop_or_default]
55
61
  pub initial_size: Option<i32>,
62
+
63
+ /// When `true`, a divider drag does NOT apply pane sizes directly:
64
+ /// `MoveResizing` only emits `on_resize` with the proposed dims, and pane
65
+ /// 0's committed size is driven exclusively by the controlled
66
+ /// [`Self::size`] prop — set by the parent when it is ready (e.g. after
67
+ /// pre-rendering panel content at the target size). This is the presize
68
+ /// gate for continuous divider drags (`PRESIZE_EVERYWHERE_PLAN.md`,
69
+ /// P1): geometry never outruns content. Only meaningful when the
70
+ /// resizable pane is child 0 (as in the viewer's `app_panel`).
71
+ #[prop_or_default]
72
+ pub deferred: bool,
73
+
74
+ /// Controlled committed size for pane 0 (deferred mode). Unlike
75
+ /// [`Self::initial_size`] (read once at `create`), changes to this prop
76
+ /// re-derive pane 0's style on every `changed`.
77
+ #[prop_or_default]
78
+ pub size: Option<i32>,
79
+ }
80
+
81
+ /// `true` when `node` renders NOTHING: an empty fragment, or a fragment of
82
+ /// nothing but empty fragments. SEMANTIC, not literal — the `html!`
83
+ /// if-syntax (and other wrappers) nests an empty branch inside another
84
+ /// `VList` rather than yielding a bare `<></>`, which is why a literal
85
+ /// `x != html! { <></> }` comparison is not a usable emptiness test (see
86
+ /// the mechanism unit test). Deliberately conservative: `VText("")` and
87
+ /// other node kinds are NOT considered empty.
88
+ fn is_empty_html(node: &Html) -> bool {
89
+ match node {
90
+ Html::VList(list) => list.iter().all(is_empty_html),
91
+ _ => false,
92
+ }
93
+ }
94
+
95
+ /// The fixed-size pane style for a committed size (the complement pane
96
+ /// flex-fills).
97
+ fn size_style(orientation: Orientation, x: i32) -> String {
98
+ match orientation {
99
+ Orientation::Horizontal => {
100
+ format!("max-width:{x}px;min-width:{x}px;width:{x}px")
101
+ },
102
+ Orientation::Vertical => {
103
+ format!("max-height:{x}px;min-height:{x}px;height:{x}px")
104
+ },
105
+ }
56
106
  }
57
107
 
58
108
  impl SplitPanelProps {
@@ -64,9 +114,12 @@ impl SplitPanelProps {
64
114
  impl PartialEq for SplitPanelProps {
65
115
  fn eq(&self, other: &Self) -> bool {
66
116
  self.id == other.id
117
+ && self.class == other.class
67
118
  && self.children == other.children
68
119
  && self.orientation == other.orientation
69
120
  && self.reverse == other.reverse
121
+ && self.size == other.size
122
+ && self.deferred == other.deferred
70
123
  }
71
124
  }
72
125
 
@@ -111,15 +164,8 @@ impl Component for SplitPanel {
111
164
  let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
112
165
 
113
166
  let mut styles = vec![Default::default(); len];
114
- if let Some(x) = &ctx.props().initial_size {
115
- styles[0] = Some(match ctx.props().orientation {
116
- Orientation::Horizontal => {
117
- format!("max-width:{x}px;min-width:{x}px;width:{x}px")
118
- },
119
- Orientation::Vertical => {
120
- format!("max-height:{x}px;min-height:{x}px;height:{x}px")
121
- },
122
- });
167
+ if let Some(x) = ctx.props().size.or(ctx.props().initial_size) {
168
+ styles[0] = Some(size_style(ctx.props().orientation, x));
123
169
  }
124
170
 
125
171
  Self {
@@ -155,7 +201,12 @@ impl Component for SplitPanel {
155
201
  cb.emit(state.get_dimensions(client_offset));
156
202
  }
157
203
 
158
- self.styles[state.index] = state.get_style(client_offset);
204
+ // Deferred mode: propose only — the committed size arrives
205
+ // back through the controlled `size` prop once the parent
206
+ // has pre-rendered content at the target.
207
+ if !ctx.props().deferred {
208
+ self.styles[state.index] = state.get_style(client_offset);
209
+ }
159
210
  }
160
211
  },
161
212
  };
@@ -173,20 +224,93 @@ impl Component for SplitPanel {
173
224
  let new_len = ctx.props().children.len();
174
225
  self.refs.resize_with(new_len, Default::default);
175
226
  self.styles.resize(new_len, Default::default());
227
+
228
+ if let Some(state) = self.resize_state.as_ref() {
229
+ let skip_empty = ctx.props().skip_empty;
230
+ let still_visible = ctx
231
+ .props()
232
+ .children
233
+ .iter()
234
+ .enumerate()
235
+ .any(|(i, x)| i == state.index && (!skip_empty || !is_empty_html(&x)));
236
+
237
+ if !still_visible {
238
+ self.resize_state = None;
239
+ }
240
+ }
241
+
242
+ // Deferred mode: pane 0's style tracks the controlled `size` prop
243
+ // (`None` = natural width, e.g. after a divider double-click reset).
244
+ if ctx.props().deferred {
245
+ self.styles[0] = ctx
246
+ .props()
247
+ .size
248
+ .map(|x| size_style(ctx.props().orientation, x));
249
+ }
250
+
176
251
  true
177
252
  }
178
253
 
179
254
  fn view(&self, ctx: &Context<Self>) -> Html {
180
- let iter = ctx
255
+ let skip_empty = ctx.props().skip_empty;
256
+ let orientation = ctx.props().orientation;
257
+
258
+ // Pair each *visible* pane with its ORIGINAL child index and key every
259
+ // pane (and divider) by that stable identity — not its post-filter
260
+ // position. Toggling one pane in/out (e.g. the settings sidebar) then
261
+ // never shifts a sibling's key, so Yew reconciles the survivor in place
262
+ // rather than remounting it. A `MainPanel` remount here would tear down
263
+ // and recreate the embedded `<regular-layout>` and the `<slot>`s
264
+ // projecting the plugins — the bug this avoids. Dividers are independent
265
+ // keyed nodes for the same reason: a divider appearing before a pane must
266
+ // not alter that pane's own keyed subtree.
267
+ let panes = ctx
181
268
  .props()
182
269
  .children
183
270
  .iter()
184
- .filter(|x| !ctx.props().skip_empty || x != &html! { <></> })
185
271
  .enumerate()
272
+ .filter(|(_, x)| !skip_empty || !is_empty_html(x))
186
273
  .collect::<Vec<_>>();
187
274
 
188
- let orientation = ctx.props().orientation;
275
+ let last = panes.len().saturating_sub(1);
276
+ let mut nodes: Vec<Html> = Vec::with_capacity(panes.len() * 2);
277
+ let mut prev: Option<usize> = None;
278
+ for (pos, (i, x)) in panes.into_iter().enumerate() {
279
+ // A divider precedes every visible pane except the first; it resizes
280
+ // the *previous* visible pane (`prev`, an original index).
281
+ if let Some(p) = prev {
282
+ nodes.push(html! {
283
+ <SplitPanelDivider
284
+ key={format!("divider-{i}")}
285
+ i={p}
286
+ {orientation}
287
+ link={ctx.link().clone()}
288
+ />
289
+ });
290
+ }
291
+
292
+ // The last visible pane flex-fills (rendered bare, no width
293
+ // override); earlier panes are `SplitPanelChild`s that can carry a
294
+ // dragged size.
295
+ nodes.push(if pos == last {
296
+ html! { <key={i}>{ x }</> }
297
+ } else {
298
+ html! {
299
+ <SplitPanelChild
300
+ key={i}
301
+ style={self.styles[i].clone()}
302
+ ref_={self.refs[i].clone()}
303
+ >
304
+ { x }
305
+ </SplitPanelChild>
306
+ }
307
+ });
308
+
309
+ prev = Some(i);
310
+ }
311
+
189
312
  let mut classes = classes!("split-panel");
313
+ classes.extend(ctx.props().class.clone());
190
314
  if orientation == Orientation::Vertical {
191
315
  classes.push("orient-vertical");
192
316
  }
@@ -195,53 +319,7 @@ impl Component for SplitPanel {
195
319
  classes.push("orient-reverse");
196
320
  }
197
321
 
198
- let count = iter.len();
199
- let contents = html! {
200
- <>
201
- for (i, x) in iter {
202
- if i == 0 {
203
- if count == 1 {
204
- <key=0>
205
- {x}
206
- </>
207
- } else {
208
- <SplitPanelChild
209
- key=0
210
- style={self.styles[i].clone()}
211
- ref_={self.refs[i].clone()}
212
- >
213
- { x }
214
- </SplitPanelChild>
215
- }
216
- } else if i == count - 1 {
217
- <key={i}>
218
- <SplitPanelDivider
219
- i={i - 1}
220
- orientation={ctx.props().orientation}
221
- link={ctx.link().clone()}
222
- />
223
- { x }
224
- </>
225
- } else {
226
- <key={i}>
227
- <SplitPanelDivider
228
- i={i - 1}
229
- orientation={ctx.props().orientation}
230
- link={ctx.link().clone()}
231
- />
232
- <SplitPanelChild
233
- style={self.styles[i].clone()}
234
- ref_={self.refs[i].clone()}
235
- >
236
- { x }
237
- </SplitPanelChild>
238
- </>
239
- }
240
- }
241
- </>
242
- };
243
-
244
- // TODO consider removing this
322
+ let contents = html! { <>{ for nodes.into_iter() }</> };
245
323
  if ctx.props().no_wrap {
246
324
  html! { { contents } }
247
325
  } else {
@@ -490,3 +568,44 @@ impl ResizingState {
490
568
  Ok(self.body_style.set_property("cursor", &self.cursor)?)
491
569
  }
492
570
  }
571
+
572
+ #[cfg(test)]
573
+ mod tests {
574
+ use super::*;
575
+
576
+ #[test]
577
+ fn html_if_empty_branch_is_not_literally_empty() {
578
+ let via_if: Html = html! { if false { <div /> } else { <></> } };
579
+ assert_ne!(via_if, html! { <></> });
580
+ }
581
+
582
+ #[test]
583
+ fn empty_fragment_is_empty() {
584
+ assert!(is_empty_html(&html! { <></> }));
585
+ }
586
+
587
+ #[test]
588
+ fn html_if_empty_branch_is_semantically_empty() {
589
+ assert!(is_empty_html(&html! { if false { <div/> } else { <></> } }));
590
+ }
591
+
592
+ #[test]
593
+ fn nested_empty_fragments_are_empty() {
594
+ assert!(is_empty_html(&html! { <><><></></></> }));
595
+ }
596
+
597
+ #[test]
598
+ fn tag_is_not_empty() {
599
+ assert!(!is_empty_html(&html! { <div/> }));
600
+ }
601
+
602
+ #[test]
603
+ fn fragment_containing_a_tag_is_not_empty() {
604
+ assert!(!is_empty_html(&html! { <><div/></> }));
605
+ }
606
+
607
+ #[test]
608
+ fn text_is_not_empty() {
609
+ assert!(!is_empty_html(&html! { { "" } }));
610
+ }
611
+ }
@@ -0,0 +1,130 @@
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
+ use web_sys::MouseEvent;
14
+ use yew::prelude::*;
15
+
16
+ /// A single command row in a [`ContextMenu`].
17
+ #[derive(Clone, PartialEq)]
18
+ pub struct ContextMenuItem {
19
+ pub label: String,
20
+ pub on_select: Callback<()>,
21
+ pub disabled: bool,
22
+ }
23
+
24
+ /// A row in a [`ContextMenu`]: a plain command [`ContextMenuItem`], a
25
+ /// non-interactive group label, or a row with a hover-opened sub-menu flyout.
26
+ #[derive(Clone, PartialEq)]
27
+ pub enum ContextMenuEntry {
28
+ Item(ContextMenuItem),
29
+ Header(String),
30
+ Submenu {
31
+ label: String,
32
+
33
+ /// Click-through command for the parent row itself (`None` makes the
34
+ /// row hover-only).
35
+ on_select: Option<Callback<()>>,
36
+ entries: Vec<ContextMenuEntry>,
37
+ },
38
+ }
39
+
40
+ #[derive(Properties, PartialEq)]
41
+ pub struct ContextMenuProps {
42
+ pub entries: Vec<ContextMenuEntry>,
43
+
44
+ /// Fired after an item is selected. Outside-click dismissal is the
45
+ /// enclosing `PortalModal`'s job (blur), not this component's.
46
+ #[prop_or_default]
47
+ pub on_close: Callback<()>,
48
+ }
49
+
50
+ /// The command-item list of a panel context menu. Purely presentational — the
51
+ /// command set is supplied by the parent as plain [`Callback`]s, and
52
+ /// positioning/dismissal/theming belong to the enclosing `PortalModal` (the
53
+ /// same body-mounted, `theme`-attributed delivery as the Export/Copy menus).
54
+ /// Sub-menus are CSS-only `:hover` flyouts nested inside their parent row, so
55
+ /// they live inside the same `PortalModal` host (focus never leaves it) and
56
+ /// selection routes through the same `on_select` + `on_close` path.
57
+ pub struct ContextMenu;
58
+
59
+ impl Component for ContextMenu {
60
+ type Message = ();
61
+ type Properties = ContextMenuProps;
62
+
63
+ fn create(_ctx: &Context<Self>) -> Self {
64
+ Self
65
+ }
66
+
67
+ fn view(&self, ctx: &Context<Self>) -> Html {
68
+ let props = ctx.props();
69
+ let entries = props
70
+ .entries
71
+ .iter()
72
+ .map(|entry| render_entry(entry, &props.on_close));
73
+
74
+ html! { <div class="context-menu">{ for entries }</div> }
75
+ }
76
+ }
77
+
78
+ fn render_entry(entry: &ContextMenuEntry, on_close: &Callback<()>) -> Html {
79
+ match entry {
80
+ ContextMenuEntry::Item(item) => {
81
+ let disabled = item.disabled;
82
+ let onclick = {
83
+ let on_select = item.on_select.clone();
84
+ let on_close = on_close.clone();
85
+ Callback::from(move |e: MouseEvent| {
86
+ e.stop_propagation();
87
+ if !disabled {
88
+ on_select.emit(());
89
+ on_close.emit(());
90
+ }
91
+ })
92
+ };
93
+
94
+ let mut class = classes!("context-menu-item");
95
+ if disabled {
96
+ class.push("disabled");
97
+ }
98
+
99
+ html! { <span {class} {onclick}>{ item.label.clone() }</span> }
100
+ },
101
+ ContextMenuEntry::Header(label) => {
102
+ html! { <span class="context-menu-header">{ label.clone() }</span> }
103
+ },
104
+ ContextMenuEntry::Submenu {
105
+ label,
106
+ on_select,
107
+ entries,
108
+ } => {
109
+ let onclick = {
110
+ let on_select = on_select.clone();
111
+ let on_close = on_close.clone();
112
+ Callback::from(move |e: MouseEvent| {
113
+ e.stop_propagation();
114
+ if let Some(on_select) = &on_select {
115
+ on_select.emit(());
116
+ on_close.emit(());
117
+ }
118
+ })
119
+ };
120
+
121
+ let children = entries.iter().map(|entry| render_entry(entry, on_close));
122
+ html! {
123
+ <span class="context-menu-item has-submenu" {onclick}>
124
+ { label.clone() }
125
+ <div class="context-menu context-menu-submenu">{ for children }</div>
126
+ </span>
127
+ }
128
+ },
129
+ }
130
+ }
@@ -119,10 +119,12 @@ impl Component for EditableHeader {
119
119
  }
120
120
  let metadata = &ctx.props().metadata;
121
121
  let expressions = metadata.get_expression_columns();
122
+ let windows = metadata.get_window_columns();
122
123
  let found = metadata
123
124
  .get_table_columns()?
124
125
  .iter()
125
126
  .chain(expressions)
127
+ .chain(windows)
126
128
  .contains(&new_value);
127
129
  Some(!found)
128
130
  })()
@@ -240,6 +240,9 @@ impl DebugPanelProps {
240
240
  &props.session,
241
241
  &props.renderer,
242
242
  &props.presentation,
243
+ // A user-pasted config apply — an explicit request,
244
+ // same affordance as the public `restore()`.
245
+ crate::tasks::RunOrigin::Public,
243
246
  config,
244
247
  async { Ok(()) },
245
248
  )