Skip to main content

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 from a tab × (an app-initiated close): the root
65    /// removes it from the model at once and parks its engines.
66    pub on_close_panel: Callback<String>,
67
68    /// Fired with a panel id once the `regular-layout` tree no longer holds
69    /// it, so the root can eject the parked panel.
70    pub on_panel_closed: Callback<String>,
71
72    /// Fired with `(panel id, command)` when the panel context menu selects a
73    /// command the root executes (New/Duplicate/Reset/ToggleMaster/Close).
74    /// Maximize/Restore are handled HERE (this component owns the layout
75    /// element), and Export/Copy end-to-end by
76    /// [`PanelMenu`](super::panel_menu::PanelMenu) itself.
77    pub on_panel_command: Callback<(String, PanelCommand)>,
78
79    /// Snapshots threaded from root.  Read for `has_table`, `title` here in
80    /// the panel itself; threaded wholesale to `StatusBar`/`StatusIndicator`.
81    pub session_props: SessionProps,
82    pub renderer_props: RendererProps,
83    pub presentation_props: PresentationProps,
84
85    /// Derived from root: `settings_open && has_table_loaded`.
86    pub is_settings_open: bool,
87
88    /// Root-managed in-flight render counter (not engine state).
89    pub update_count: u32,
90
91    /// Ids of every layout panel, in order; one `<regular-layout>` cell is
92    /// rendered per id. Drives re-render when panels are added/removed.
93    pub panel_ids: Vec<PanelId>,
94
95    /// `(panel id, session title)` for every panel. Rendered into the
96    /// `<regular-layout>` `style` as `--regular-layout-<id>--title` custom
97    /// properties, which regular-layout's tabs display via `::before` content.
98    pub panel_titles: Vec<(String, Option<String>)>,
99
100    /// `(panel id, per-panel theme)` for every panel. A snapshot so a
101    /// per-panel theme change re-renders MainPanel — `renderer.theme()` is
102    /// interior-mutable and not otherwise observed by `eq`. Each frame inlines
103    /// its theme's `--psp-*` block only when it diverges from the host theme.
104    pub panel_themes: Vec<(String, Option<String>)>,
105
106    /// The master (filter-source) panel ids, sorted. A snapshot so a master
107    /// toggle re-renders MainPanel — the role set is interior-mutable on
108    /// `Workspace` and not otherwise observed by `eq`. Drives each tab's
109    /// broadcast badge.
110    pub panel_masters: Vec<PanelId>,
111
112    /// Element-level global filters (fed by master/detail selection), threaded
113    /// to the `StatusBar` where the global-filter chips are rendered.
114    pub global_filters: Vec<perspective_client::config::Filter>,
115
116    /// Remove the global filter at this index (a chip's × in the `StatusBar`).
117    pub on_remove_global_filter: Callback<usize>,
118
119    /// Clear all global filters (the "Clear" affordance in the `StatusBar`).
120    pub on_clear_global_filters: Callback<()>,
121
122    /// The multi-panel model, for per-panel `Renderer`/`Session` access when
123    /// reconciling `insertPanel`/`removePanel`.
124    pub workspace: Workspace,
125
126    /// State (the *active* panel's handles — for the shared status bar).
127    pub session: Session,
128    pub renderer: Renderer,
129    pub presentation: Presentation,
130}
131
132impl PartialEq for MainPanelProps {
133    fn eq(&self, rhs: &Self) -> bool {
134        self.session_props == rhs.session_props
135            && self.renderer_props == rhs.renderer_props
136            && self.presentation_props == rhs.presentation_props
137            && self.is_settings_open == rhs.is_settings_open
138            && self.update_count == rhs.update_count
139            && self.panel_ids == rhs.panel_ids
140            && self.panel_titles == rhs.panel_titles
141            && self.panel_themes == rhs.panel_themes
142            && self.panel_masters == rhs.panel_masters
143            && self.global_filters == rhs.global_filters
144            && self.workspace == rhs.workspace
145            && self.session == rhs.session
146            && self.renderer == rhs.renderer
147            && self.presentation == rhs.presentation
148    }
149}
150
151impl MainPanelProps {
152    fn is_title(&self) -> bool {
153        self.session_props.title.is_some()
154    }
155
156    pub(super) fn effective_panel_theme(&self, id: &str) -> Option<String> {
157        self.panel_themes
158            .iter()
159            .find(|(pid, _)| pid == id)
160            .and_then(|(_, theme)| theme.clone())
161            .or_else(|| self.presentation_props.available_themes.first().cloned())
162    }
163}
164
165pub struct MainPanel {
166    main_panel_ref: NodeRef,
167
168    /// Ref to the `<regular-layout>` element hosting the panel cells.
169    layout_ref: NodeRef,
170
171    /// Panel slots currently placed in `layout_ref`'s grid, reconciled against
172    /// `panel_ids` on each render so we `insertPanel`/`removePanel` exactly
173    /// once per add/remove.
174    inserted: Vec<String>,
175
176    /// `regular-layout-update` listener (close detection); kept alive here and
177    /// attached to the layout element once in `rendered`.
178    _layout_update_listener: Closure<dyn FnMut(web_sys::Event)>,
179
180    /// `regular-layout-select` listener (active-panel sync); kept alive here
181    /// and attached alongside the update listener.
182    _layout_select_listener: Closure<dyn FnMut(web_sys::Event)>,
183
184    /// `regular-layout-before-resize` listener (presize hook); kept alive here
185    /// and attached alongside the others.
186    _layout_before_resize_listener: Closure<dyn FnMut(web_sys::Event)>,
187
188    /// `contextmenu` listener on the viewer HOST element — one stable attach
189    /// point covering every surface (panels, status bar, sidebars),
190    /// independent of the layout reconcile; attached once in `rendered` and
191    /// REMOVED in `destroy`, since the host outlives this component's Yew
192    /// app. Imperative — not a Yew `oncontextmenu` — because the plugin body
193    /// is light-DOM attached by the renderer (its DOM parent is the host,
194    /// not the frame), so Yew's delegated handler never matches a
195    /// right-click there; a native listener sees every surface — shadow
196    /// chrome and slotted light DOM alike — on the composed path.
197    _contextmenu_listener: Closure<dyn FnMut(web_sys::Event)>,
198
199    /// The `<regular-layout>` ELEMENT the layout listeners are attached to.
200    /// Compared by identity in `reconcile` — if the element is ever a
201    /// different instance (it should never be: the render keys it into a
202    /// fully-keyed sibling list precisely so Yew reuses it), the listeners
203    /// are re-attached and `inserted` is reset so the fresh (empty) layout
204    /// is repopulated instead of silently orphaning every panel. A latched
205    /// `bool` here once turned an unkeyed-diff element replacement into
206    /// "Duplicate commits into an EMPTY tree with no `before-resize`
207    /// listener": the old element left the DOM with the committed tree and
208    /// every listener, and nothing ever noticed.
209    listener_target: Option<web_sys::HtmlElement>,
210
211    /// Ids gone from the model whose `removePanel` has been issued to the
212    /// layout and not yet committed.
213    pending_removals: HashSet<String>,
214
215    /// Ids whose `insertPanel` has been issued to the layout and not yet
216    /// observed in a committed tree: layout commits are async, so a
217    /// multi-insert reconcile sees `regular-layout-update` events BETWEEN
218    /// commits, and close detection must not mistake the pre-commit window
219    /// for a user close (it once disposed the second of two restored
220    /// panels mid-restore).
221    pending_insertions: HashSet<String>,
222
223    /// MODEL-held panels observed absent from exactly one committed tree,
224    /// awaiting the next `regular-layout-update` (rescheduled one frame out)
225    /// to confirm a layout-originated close — an unrelated removal's commit
226    /// can report a live sibling absent transiently.
227    suspect_closes: HashSet<String>,
228
229    /// Per-panel `ResizeObserver`s, keyed by panel id, each observing that
230    /// panel's slotted plugin element and resizing only that panel's
231    /// `Renderer`. Bound in `BeforeResize` for the DRAGGED panel only (it is
232    /// excluded from the presize paths, and the `overlay` presize is disabled);
233    /// dropped on `LayoutUpdated` (the drop).
234    panel_resize_observers: HashMap<String, PanelResizeObserverHandle>,
235
236    /// Panels currently *hidden* behind an unselected index of a tab stack,
237    /// recomputed from the layout tree on every `regular-layout-update`.
238    /// Drives each tab's `visible` prop (`PanelTab` marks the front tab of
239    /// every stack, not just the active panel). Empty until the first layout
240    /// update — every panel starts visible.
241    hidden_tabs: HashSet<String>,
242
243    /// Open context menu as `(client x, client y, target panel id)`; outer
244    /// `None` when closed. A `None` *panel id* is the target-less menu (no
245    /// active panel to resolve to — "New" only). Rendered as a
246    /// cursor-anchored [`PanelMenu`](super::panel_menu::PanelMenu) overlay.
247    context_menu: Option<(f64, f64, Option<String>)>,
248
249    /// Id of the currently maximized panel (via `regular-layout.maximize`), or
250    /// `None`. Transient (regular-layout doesn't persist it); drives the
251    /// Maximize/Restore menu label. Cleared when the panel leaves the layout.
252    maximized: Option<String>,
253
254    /// Theme-name-keyed cache of backgrounds read off stamped plugin
255    /// elements, the mirror source for frames whose own plugin is unreadable
256    /// (see [`frame_theme`]). Cleared when the theme registry changes.
257    theme_backgrounds: HashMap<String, String>,
258
259    /// The inputs `stamp_frame_themes` last mirrored from; unchanged inputs
260    /// skip the pass (and its forced style recalcs) on unrelated re-renders.
261    /// `None` until the first *fully-resolved* pass — an unresolved frame
262    /// leaves it unlatched so the mirror retries each render.
263    stamped_frame_themes: Option<frame_theme::FrameThemeSnapshot>,
264
265    /// `Workspace::staged_changed` → [`MainPanelMsg::StagedChanged`] on THIS
266    /// component's scope (see the message doc for why not the root's).
267    _staged_sub: crate::utils::Subscription,
268
269    /// `Workspace::layout_staged` → [`MainPanelMsg::LayoutStaged`].
270    _layout_staged_sub: crate::utils::Subscription,
271}
272
273impl Component for MainPanel {
274    type Message = MainPanelMsg;
275    type Properties = MainPanelProps;
276
277    fn create(ctx: &Context<Self>) -> Self {
278        let cb = ctx.link().callback(|_: ()| MainPanelMsg::LayoutUpdated);
279        let listener = Closure::wrap(
280            Box::new(move |_: web_sys::Event| cb.emit(())) as Box<dyn FnMut(web_sys::Event)>
281        );
282
283        let select_cb = ctx.link().callback(MainPanelMsg::TabSelected);
284        let select_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
285            #[derive(serde::Deserialize)]
286            struct SelectDetail {
287                name: String,
288            }
289
290            if let Some(custom) = event.dyn_ref::<web_sys::CustomEvent>()
291                && let Ok(SelectDetail { name }) = custom.detail().into_serde_ext()
292            {
293                select_cb.emit(name);
294            }
295        }) as Box<dyn FnMut(web_sys::Event)>);
296
297        // `preventDefault()` synchronously suspends the layout's resize commit
298        // (the event is cancelable); the component then pre-sizes each panel
299        // and calls `resumeResize` to release it (see
300        // `MainPanelMsg::BeforeResize`).
301        let before_resize_cb = ctx.link().callback(MainPanelMsg::BeforeResize);
302        let before_resize_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
303            event.prevent_default();
304            before_resize_cb.emit(event);
305        }) as Box<dyn FnMut(web_sys::Event)>);
306
307        // Imperative `contextmenu` listener: a right-click anywhere in the
308        // element opens the panel menu — targeting the frame on the event's
309        // composed path, or `None` (resolved to the ACTIVE panel, else the
310        // stage menu, in `on_context_menu`, where props live) from any
311        // other surface. Two pass-throughs keep the NATIVE menu: editable
312        // targets (inputs, textareas, contenteditable — cut/copy/paste),
313        // and anything inside an open `PortalModal` (every modal host is a
314        // light-DOM viewer child in the modal slot — this menu included, so
315        // without the carve-out a right-click in any open dropdown would
316        // re-open the menu underneath it).
317        let contextmenu_cb = ctx
318            .link()
319            .callback(|(id, x, y)| MainPanelMsg::ContextMenu(id, x, y));
320        let viewer_elem = ctx.props().presentation.viewer_elem().clone();
321        let contextmenu_listener = Closure::wrap(Box::new(move |event: web_sys::Event| {
322            // Shift+right-click passes through to the native browser menu.
323            if event.unchecked_ref::<web_sys::MouseEvent>().shift_key() {
324                return;
325            }
326
327            let path = event.composed_path();
328            let is_editable = path.get(0).dyn_ref::<web_sys::HtmlElement>().is_some_and(
329                |el: &web_sys::HtmlElement| {
330                    el.tag_name().eq_ignore_ascii_case("input")
331                        || el.tag_name().eq_ignore_ascii_case("textarea")
332                        || el.is_content_editable()
333                },
334            );
335
336            if is_editable {
337                return;
338            }
339
340            let mut panel_id = None;
341            for i in 0..path.length() {
342                let node = path.get(i);
343                let Some(el) = node.dyn_ref::<web_sys::Element>() else {
344                    continue;
345                };
346
347                if el == viewer_elem.unchecked_ref::<web_sys::Element>() {
348                    break;
349                }
350
351                if el.slot() == crate::ui::MODAL_SLOT {
352                    return;
353                }
354
355                if el.tag_name().eq_ignore_ascii_case("regular-layout-frame")
356                    && let Some(name) = el.get_attribute("name")
357                {
358                    panel_id = Some(name);
359                    break;
360                }
361            }
362
363            event.prevent_default();
364            let mouse = event.unchecked_ref::<web_sys::MouseEvent>();
365            contextmenu_cb.emit((panel_id, mouse.client_x() as f64, mouse.client_y() as f64));
366        }) as Box<dyn FnMut(web_sys::Event)>);
367
368        let staged_sub = {
369            use crate::utils::AddListener;
370            let cb = ctx.link().callback(|_: ()| MainPanelMsg::StagedChanged);
371            ctx.props()
372                .workspace
373                .staged_changed()
374                .add_listener(move |()| cb.emit(()))
375        };
376
377        let layout_staged_sub = {
378            use crate::utils::AddListener;
379            let cb = ctx.link().callback(|_: ()| MainPanelMsg::LayoutStaged);
380            ctx.props()
381                .workspace
382                .layout_staged()
383                .add_listener(move |()| cb.emit(()))
384        };
385
386        Self {
387            main_panel_ref: NodeRef::default(),
388            layout_ref: NodeRef::default(),
389            inserted: Vec::new(),
390            pending_removals: HashSet::new(),
391            pending_insertions: HashSet::new(),
392            suspect_closes: HashSet::new(),
393            _layout_update_listener: listener,
394            _layout_select_listener: select_listener,
395            _layout_before_resize_listener: before_resize_listener,
396            _contextmenu_listener: contextmenu_listener,
397            listener_target: None,
398            panel_resize_observers: HashMap::new(),
399            hidden_tabs: HashSet::new(),
400            context_menu: None,
401            maximized: None,
402            theme_backgrounds: HashMap::new(),
403            stamped_frame_themes: None,
404            _staged_sub: staged_sub,
405            _layout_staged_sub: layout_staged_sub,
406        }
407    }
408
409    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
410        match msg {
411            MainPanelMsg::PointerEvent(event) => self.on_pointer_event(ctx, event),
412            MainPanelMsg::StagedChanged => true,
413            MainPanelMsg::LayoutStaged => true,
414            MainPanelMsg::LayoutUpdated => self.on_layout_updated(ctx),
415            MainPanelMsg::TabSelected(name) => self.on_tab_selected(ctx, name),
416            MainPanelMsg::ContextMenu(id, x, y) => self.on_context_menu(ctx, id, x, y),
417            MainPanelMsg::CloseContextMenu => self.on_close_context_menu(),
418            MainPanelMsg::Command(cmd) => self.on_command(ctx, cmd),
419            MainPanelMsg::BeforeResize(event) => self.on_before_resize(ctx, event),
420        }
421    }
422
423    fn changed(&mut self, ctx: &Context<Self>, old: &Self::Properties) -> bool {
424        self.close_unstaged_panels(ctx, old);
425        ctx.props() != old
426    }
427
428    fn rendered(&mut self, ctx: &Context<Self>, first_render: bool) {
429        // The `contextmenu` listener attaches to the viewer HOST element —
430        // one stable attach point covering every surface (panels, status
431        // bar, sidebars), decoupled from the layout reconcile. Panel
432        // right-clicks reach it through their frames on the composed path;
433        // everything else resolves to the active panel (or the stage menu).
434        if first_render {
435            let _ = ctx
436                .props()
437                .presentation
438                .viewer_elem()
439                .add_event_listener_with_callback(
440                    "contextmenu",
441                    self._contextmenu_listener.as_ref().unchecked_ref(),
442                );
443        }
444
445        self.size_staging_wrappers();
446        self.reconcile(ctx);
447        self.stamp_frame_themes(ctx);
448    }
449
450    fn view(&self, ctx: &Context<Self>) -> Html {
451        self.render(ctx)
452    }
453
454    fn destroy(&mut self, ctx: &Context<Self>) {
455        // The host element outlives this component (`delete()` tears down
456        // the Yew app, not the custom element), so the listener must not
457        // dangle into a dropped `Closure`.
458        let _ = ctx
459            .props()
460            .presentation
461            .viewer_elem()
462            .remove_event_listener_with_callback(
463                "contextmenu",
464                self._contextmenu_listener.as_ref().unchecked_ref(),
465            );
466    }
467}
468
469impl MainPanel {
470    /// Report closed every panel gone from the model that the layout never
471    /// held, since it has no commit to wait for.
472    fn close_unstaged_panels(&self, ctx: &Context<Self>, old: &MainPanelProps) {
473        for id in &old.panel_ids {
474            if !ctx.props().panel_ids.contains(id)
475                && !self.inserted.iter().any(|n| n == id.as_str())
476            {
477                ctx.props().on_panel_closed.emit(id.as_str().to_owned());
478            }
479        }
480    }
481}