perspective_viewer/components/main_panel.rs
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//! `MainPanel`: the multi-panel layout host. Owns the `<regular-layout>`
14//! element, the per-panel `<PanelTab>`s, the shared status bar, and the panel
15//! context menu. The `Component` impl below is thin — its handlers live in the
16//! submodules:
17//!
18//! - [`update`] — the small `MainPanelMsg` handlers (pointer, close detection,
19//! tab/active sync, context menu).
20//! - [`presize`] — the `BeforeResize` pre-size-every-plugin algorithm.
21//! - [`reconcile`] — the `rendered` layout reconcile + per-panel theme stamp.
22//! - [`frame_theme`] — the `rendered` frame-background mirror (each
23//! `<regular-layout-frame>`'s panel-theme background var).
24//! - [`render`] — the `view` (status bar, cells, tabs, menu).
25
26mod frame_theme;
27mod presize;
28mod reconcile;
29mod render;
30mod update;
31
32pub mod msg;
33
34use std::collections::{HashMap, HashSet};
35
36use perspective_js::utils::JsValueSerdeExt;
37use wasm_bindgen::prelude::*;
38use yew::prelude::*;
39
40pub use self::msg::MainPanelMsg;
41use super::panel_menu::PanelCommand;
42use crate::presentation::{Presentation, PresentationProps};
43use crate::renderer::*;
44use crate::session::{Session, SessionProps};
45use crate::tasks::PanelResizeObserverHandle;
46use crate::workspace::{PanelId, Workspace};
47
48#[derive(Clone, Properties)]
49pub struct MainPanelProps {
50 /// Toggle the settings sidebar open. Fired by a `PanelTab`'s open-settings
51 /// button (after selecting + activating that panel) — the tabs are the
52 /// only open-settings affordance.
53 pub on_settings: Callback<()>,
54
55 /// Reset callback forwarded from the root component. Fired when the user
56 /// clicks the reset button; `bool` is `true` for a full reset (expressions
57 /// + column configs), `false` for config-only.
58 pub on_reset: Callback<bool>,
59
60 /// Fired with a panel id when its frame titlebar is pressed, to make it the
61 /// active panel.
62 pub on_activate_panel: Callback<String>,
63
64 /// Fired with a panel id when its frame's close button removes it from the
65 /// layout, so the root can dispose the panel.
66 pub on_close_panel: Callback<String>,
67
68 /// Fired with `(panel id, command)` when the panel context menu selects a
69 /// command the root executes (New/Duplicate/Reset/ToggleMaster/Close).
70 /// Maximize/Restore are handled HERE (this component owns the layout
71 /// element), and Export/Copy end-to-end by
72 /// [`PanelMenu`](super::panel_menu::PanelMenu) itself.
73 pub on_panel_command: Callback<(String, PanelCommand)>,
74
75 /// Snapshots threaded from root. Read for `has_table`, `title` here in
76 /// the panel itself; threaded wholesale to `StatusBar`/`StatusIndicator`.
77 pub session_props: SessionProps,
78 pub renderer_props: RendererProps,
79 pub presentation_props: PresentationProps,
80
81 /// Derived from root: `settings_open && has_table_loaded`.
82 pub is_settings_open: bool,
83
84 /// Root-managed in-flight render counter (not engine state).
85 pub update_count: u32,
86
87 /// Ids of every layout panel, in order; one `<regular-layout>` cell is
88 /// rendered per id. Drives re-render when panels are added/removed.
89 pub panel_ids: Vec<PanelId>,
90
91 /// `(panel id, session title)` for every panel. Rendered into the
92 /// `<regular-layout>` `style` as `--regular-layout-<id>--title` custom
93 /// properties, which regular-layout's tabs display via `::before` content.
94 pub panel_titles: Vec<(String, Option<String>)>,
95
96 /// `(panel id, per-panel theme)` for every panel. A snapshot so a
97 /// per-panel theme change re-renders MainPanel — `renderer.theme()` is
98 /// interior-mutable and not otherwise observed by `eq`. Each frame inlines
99 /// its theme's `--psp-*` block only when it diverges from the host theme.
100 pub panel_themes: Vec<(String, Option<String>)>,
101
102 /// The master (filter-source) panel ids, sorted. A snapshot so a master
103 /// toggle re-renders MainPanel — the role set is interior-mutable on
104 /// `Workspace` and not otherwise observed by `eq`. Drives each tab's
105 /// broadcast badge.
106 pub panel_masters: Vec<PanelId>,
107
108 /// Element-level global filters (fed by master/detail selection), threaded
109 /// to the `StatusBar` where the global-filter chips are rendered.
110 pub global_filters: Vec<perspective_client::config::Filter>,
111
112 /// Remove the global filter at this index (a chip's × in the `StatusBar`).
113 pub on_remove_global_filter: Callback<usize>,
114
115 /// Clear all global filters (the "Clear" affordance in the `StatusBar`).
116 pub on_clear_global_filters: Callback<()>,
117
118 /// The multi-panel model, for per-panel `Renderer`/`Session` access when
119 /// reconciling `insertPanel`/`removePanel`.
120 pub workspace: Workspace,
121
122 /// State (the *active* panel's handles — for the shared status bar).
123 pub session: Session,
124 pub renderer: Renderer,
125 pub presentation: Presentation,
126}
127
128impl PartialEq for MainPanelProps {
129 fn eq(&self, rhs: &Self) -> bool {
130 self.session_props == rhs.session_props
131 && self.renderer_props == rhs.renderer_props
132 && self.presentation_props == rhs.presentation_props
133 && self.is_settings_open == rhs.is_settings_open
134 && self.update_count == rhs.update_count
135 && self.panel_ids == rhs.panel_ids
136 && self.panel_titles == rhs.panel_titles
137 && self.panel_themes == rhs.panel_themes
138 && self.panel_masters == rhs.panel_masters
139 && self.global_filters == rhs.global_filters
140 && self.workspace == rhs.workspace
141 && self.session == rhs.session
142 && self.renderer == rhs.renderer
143 && self.presentation == rhs.presentation
144 }
145}
146
147impl MainPanelProps {
148 fn is_title(&self) -> bool {
149 self.session_props.title.is_some()
150 }
151
152 pub(super) fn effective_panel_theme(&self, id: &str) -> Option<String> {
153 self.panel_themes
154 .iter()
155 .find(|(pid, _)| pid == id)
156 .and_then(|(_, theme)| theme.clone())
157 .or_else(|| self.presentation_props.available_themes.first().cloned())
158 }
159}
160
161pub struct MainPanel {
162 main_panel_ref: NodeRef,
163
164 /// Ref to the `<regular-layout>` element hosting the panel cells.
165 layout_ref: NodeRef,
166
167 /// Panel slots currently placed in `layout_ref`'s grid, reconciled against
168 /// `panel_ids` on each render so we `insertPanel`/`removePanel` exactly
169 /// once per add/remove.
170 inserted: Vec<String>,
171
172 /// `regular-layout-update` listener (close detection); kept alive here and
173 /// attached to the layout element once in `rendered`.
174 _layout_update_listener: Closure<dyn FnMut(web_sys::Event)>,
175
176 /// `regular-layout-select` listener (active-panel sync); kept alive here
177 /// and attached alongside the update listener.
178 _layout_select_listener: Closure<dyn FnMut(web_sys::Event)>,
179
180 /// `regular-layout-before-resize` listener (presize hook); kept alive here
181 /// and attached alongside the others.
182 _layout_before_resize_listener: Closure<dyn FnMut(web_sys::Event)>,
183
184 /// `contextmenu` listener on the panel *container* — one stable attach
185 /// point covering the whole stage, independent of the layout reconcile;
186 /// at zero panels it opens the stage menu. Kept alive here and attached
187 /// once in `rendered`. Imperative — not a Yew `oncontextmenu` — because
188 /// the plugin body is light-DOM attached by the renderer (its DOM parent
189 /// is the host, not the frame), so Yew's delegated handler never matches
190 /// a right-click there. A native listener catches it via composed
191 /// bubbling and resolves the panel from the path.
192 _contextmenu_listener: Closure<dyn FnMut(web_sys::Event)>,
193
194 /// The `<regular-layout>` ELEMENT the layout listeners are attached to.
195 /// Compared by identity in `reconcile` — if the element is ever a
196 /// different instance (it should never be: the render keys it into a
197 /// fully-keyed sibling list precisely so Yew reuses it), the listeners
198 /// are re-attached and `inserted` is reset so the fresh (empty) layout
199 /// is repopulated instead of silently orphaning every panel. A latched
200 /// `bool` here once turned an unkeyed-diff element replacement into
201 /// "Duplicate commits into an EMPTY tree with no `before-resize`
202 /// listener": the old element left the DOM with the committed tree and
203 /// every listener, and nothing ever noticed.
204 listener_target: Option<web_sys::HtmlElement>,
205
206 /// Per-panel `ResizeObserver`s, keyed by panel id, each observing that
207 /// panel's slotted plugin element and resizing only that panel's
208 /// `Renderer`. Bound in `BeforeResize` for the DRAGGED panel only (it is
209 /// excluded from the presize paths, and the `overlay` presize is disabled);
210 /// dropped on `LayoutUpdated` (the drop).
211 panel_resize_observers: HashMap<String, PanelResizeObserverHandle>,
212
213 /// Panels currently *hidden* behind an unselected index of a tab stack,
214 /// recomputed from the layout tree on every `regular-layout-update`.
215 /// Drives each tab's `visible` prop (`PanelTab` marks the front tab of
216 /// every stack, not just the active panel). Empty until the first layout
217 /// update — every panel starts visible.
218 hidden_tabs: HashSet<String>,
219
220 /// Open context menu as `(client x, client y, target panel id)`; outer
221 /// `None` when closed. A `None` *panel id* is the empty-stage menu (zero
222 /// panels — "New" only). Rendered as a cursor-anchored
223 /// [`PanelMenu`](super::panel_menu::PanelMenu) overlay.
224 context_menu: Option<(f64, f64, Option<String>)>,
225
226 /// Id of the currently maximized panel (via `regular-layout.maximize`), or
227 /// `None`. Transient (regular-layout doesn't persist it); drives the
228 /// Maximize/Restore menu label. Cleared when the panel leaves the layout.
229 maximized: Option<String>,
230
231 /// Theme-name-keyed cache of backgrounds read off stamped plugin
232 /// elements, the mirror source for frames whose own plugin is unreadable
233 /// (see [`frame_theme`]). Cleared when the theme registry changes.
234 theme_backgrounds: HashMap<String, String>,
235
236 /// The inputs `stamp_frame_themes` last mirrored from; unchanged inputs
237 /// skip the pass (and its forced style recalcs) on unrelated re-renders.
238 /// `None` until the first *fully-resolved* pass — an unresolved frame
239 /// leaves it unlatched so the mirror retries each render.
240 stamped_frame_themes: Option<frame_theme::FrameThemeSnapshot>,
241
242 /// `Workspace::staged_changed` → [`MainPanelMsg::StagedChanged`] on THIS
243 /// component's scope (see the message doc for why not the root's).
244 _staged_sub: crate::utils::Subscription,
245}
246
247impl Component for MainPanel {
248 type Message = MainPanelMsg;
249 type Properties = MainPanelProps;
250
251 fn create(ctx: &Context<Self>) -> Self {
252 let cb = ctx.link().callback(|_: ()| MainPanelMsg::LayoutUpdated);
253 let listener = Closure::wrap(
254 Box::new(move |_: web_sys::Event| cb.emit(())) as Box<dyn FnMut(web_sys::Event)>
255 );
256
257 let select_cb = ctx.link().callback(MainPanelMsg::TabSelected);
258 let select_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
259 #[derive(serde::Deserialize)]
260 struct SelectDetail {
261 name: String,
262 }
263
264 if let Some(custom) = event.dyn_ref::<web_sys::CustomEvent>()
265 && let Ok(SelectDetail { name }) = custom.detail().into_serde_ext()
266 {
267 select_cb.emit(name);
268 }
269 }) as Box<dyn FnMut(web_sys::Event)>);
270
271 // `preventDefault()` synchronously suspends the layout's resize commit
272 // (the event is cancelable); the component then pre-sizes each panel and
273 // calls `resumeResize` to release it (see `MainPanelMsg::BeforeResize`).
274 let before_resize_cb = ctx.link().callback(MainPanelMsg::BeforeResize);
275 let before_resize_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
276 event.prevent_default();
277 before_resize_cb.emit(event);
278 }) as Box<dyn FnMut(web_sys::Event)>);
279
280 // Imperative `contextmenu` listener: a right-click anywhere in a panel
281 // (the plugin body included) opens the panel menu. The plugin body is
282 // light-DOM attached by the renderer, so a Yew `oncontextmenu` on the
283 // frame never matches it (Yew walks the vdom, not the composed path).
284 // This native listener resolves the panel from the
285 // `<regular-layout-frame name=…>` on the event's composed path, then
286 // suppresses the browser menu and emits. On the EMPTY stage (zero
287 // panels — the persistent `<regular-layout>` has no frame
288 // descendants), it opens the stage menu instead, whose "New" items
289 // create the first panel.
290 let contextmenu_cb = ctx
291 .link()
292 .callback(|(id, x, y)| MainPanelMsg::ContextMenu(id, x, y));
293 let contextmenu_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
294 // Shift+right-click passes through to the native browser menu.
295 if event.unchecked_ref::<web_sys::MouseEvent>().shift_key() {
296 return;
297 }
298
299 let path = event.composed_path();
300 let mut panel_id = None;
301 for i in 0..path.length() {
302 let node = path.get(i);
303 if let Some(el) = node.dyn_ref::<web_sys::Element>()
304 && el.tag_name().eq_ignore_ascii_case("regular-layout-frame")
305 && let Some(name) = el.get_attribute("name")
306 {
307 panel_id = Some(name);
308 break;
309 }
310 }
311
312 let is_empty_stage = || {
313 event
314 .current_target()
315 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
316 .is_some_and(|el| {
317 el.query_selector("regular-layout-frame")
318 .ok()
319 .flatten()
320 .is_none()
321 })
322 };
323
324 if let Some(id) = panel_id {
325 event.prevent_default();
326 let mouse = event.unchecked_ref::<web_sys::MouseEvent>();
327 contextmenu_cb.emit((Some(id), mouse.client_x() as f64, mouse.client_y() as f64));
328 } else if is_empty_stage() {
329 event.prevent_default();
330 let mouse = event.unchecked_ref::<web_sys::MouseEvent>();
331 contextmenu_cb.emit((None, mouse.client_x() as f64, mouse.client_y() as f64));
332 }
333 // With panels present, a click outside every frame lets the
334 // native menu through.
335 }) as Box<dyn FnMut(web_sys::Event)>);
336
337 let staged_sub = {
338 use crate::utils::AddListener;
339 let cb = ctx.link().callback(|_: ()| MainPanelMsg::StagedChanged);
340 ctx.props()
341 .workspace
342 .staged_changed()
343 .add_listener(move |()| cb.emit(()))
344 };
345
346 Self {
347 main_panel_ref: NodeRef::default(),
348 layout_ref: NodeRef::default(),
349 inserted: Vec::new(),
350 _layout_update_listener: listener,
351 _layout_select_listener: select_listener,
352 _layout_before_resize_listener: before_resize_listener,
353 _contextmenu_listener: contextmenu_listener,
354 listener_target: None,
355 panel_resize_observers: HashMap::new(),
356 hidden_tabs: HashSet::new(),
357 context_menu: None,
358 maximized: None,
359 theme_backgrounds: HashMap::new(),
360 stamped_frame_themes: None,
361 _staged_sub: staged_sub,
362 }
363 }
364
365 fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
366 match msg {
367 MainPanelMsg::PointerEvent(event) => self.on_pointer_event(ctx, event),
368 MainPanelMsg::StagedChanged => true,
369 MainPanelMsg::LayoutUpdated => self.on_layout_updated(ctx),
370 MainPanelMsg::TabSelected(name) => self.on_tab_selected(ctx, name),
371 MainPanelMsg::ContextMenu(id, x, y) => self.on_context_menu(ctx, id, x, y),
372 MainPanelMsg::CloseContextMenu => self.on_close_context_menu(),
373 MainPanelMsg::Command(cmd) => self.on_command(ctx, cmd),
374 MainPanelMsg::BeforeResize(event) => self.on_before_resize(ctx, event),
375 }
376 }
377
378 fn changed(&mut self, ctx: &Context<Self>, old: &Self::Properties) -> bool {
379 ctx.props() != old
380 }
381
382 fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
383 // The `contextmenu` listener attaches to the panel CONTAINER — one
384 // stable attach point covering the whole stage, decoupled from the
385 // layout reconcile. Panel right-clicks bubble to it through the
386 // layout on the composed path; at zero panels it serves the stage
387 // menu.
388 if first_render && let Some(el) = self.main_panel_ref.cast::<web_sys::HtmlElement>() {
389 let _ = el.add_event_listener_with_callback(
390 "contextmenu",
391 self._contextmenu_listener.as_ref().unchecked_ref(),
392 );
393 }
394
395 self.size_staging_wrappers();
396 self.reconcile(ctx);
397 self.stamp_frame_themes(ctx);
398 }
399
400 fn view(&self, ctx: &Context<Self>) -> Html {
401 self.render(ctx)
402 }
403
404 fn destroy(&mut self, _ctx: &Context<Self>) {}
405}