Skip to main content

perspective_viewer/components/
viewer.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//! The root `<perspective-viewer>` Yew component: state, lifecycle, and the
14//! message dispatch table. Handler bodies live in the domain modules —
15//! [`panels`] (workspace panel lifecycle + active targeting), [`settings`]
16//! (settings sidebar + divider presize pump), [`filters`] (master/detail
17//! cross-filter), [`snapshots`] (value-semantic props plumbing) — with engine
18//! wiring in [`wiring`] and `view()` in [`render`]. (The panel context menu +
19//! pickers + maximize live in `MainPanel`/`PanelMenu`, which own the layout
20//! element.)
21
22mod filters;
23mod msg;
24mod panels;
25mod render;
26mod settings;
27mod snapshots;
28mod wiring;
29
30use std::collections::HashMap;
31use std::rc::Rc;
32
33use futures::channel::oneshot::Sender;
34use perspective_client::config::Filter;
35use perspective_js::utils::*;
36use wasm_bindgen::JsCast;
37use wasm_bindgen::prelude::*;
38use yew::prelude::*;
39
40pub use self::msg::PerspectiveViewerMsg;
41use self::msg::PerspectiveViewerMsg::*;
42use self::settings::SettingsGeometry;
43use self::wiring::*;
44use crate::presentation::{Presentation, PresentationProps};
45use crate::renderer::{RendererProps, *};
46use crate::session::{SessionProps, *};
47use crate::tasks::*;
48use crate::ui::{FontLoaderProps, FontLoaderStatus};
49use crate::utils::*;
50use crate::workspace::{PanelId, Workspace};
51
52#[derive(Clone, Properties)]
53pub struct PerspectiveViewerProps {
54    /// The light DOM element this component will render to.
55    pub elem: web_sys::HtmlElement,
56
57    /// State
58    pub presentation: Presentation,
59
60    /// The multi-panel model — the source of truth for engine state. The
61    /// component derives the active panel's `Session`/`Renderer` via
62    /// `workspace.active_*()` (see `create`), and renders a cell per panel.
63    pub workspace: Workspace,
64}
65
66impl PartialEq for PerspectiveViewerProps {
67    fn eq(&self, _rhs: &Self) -> bool {
68        false
69    }
70}
71
72pub struct PerspectiveViewer {
73    _subscriptions: Vec<Subscription>,
74
75    /// Per-active-panel subscriptions; dropped + recreated on `SetActivePanel`.
76    _active_subscriptions: Vec<Subscription>,
77
78    /// One `title_changed` subscription per panel (all panels, not just
79    /// active), so a title change on any panel re-renders the tab titles.
80    /// Recreated when the panel set changes (`LayoutChanged`/`ClosePanel`).
81    _title_subscriptions: Vec<Subscription>,
82
83    /// The active panel's engine handles — what the settings panel + status bar
84    /// bind to. Re-targeted on `SetActivePanel`. Fall back to
85    /// `empty_session`/`empty_renderer` when the element has zero panels.
86    active_session: Session,
87    active_renderer: Renderer,
88
89    /// Detached "empty" engine handles (never inserted into the `Workspace`,
90    /// never mounted). Used as the `active_*` fallback when there are zero
91    /// panels, so the settings/status chrome always has a `Session`+`Renderer`
92    /// to bind to — and, being tableless, renders an inert empty state.
93    empty_session: Session,
94    empty_renderer: Renderer,
95    debug_open: bool,
96    fonts: FontLoaderProps,
97    on_close_column_settings: Callback<()>,
98    on_rendered: Vec<Sender<()>>,
99    on_resize: Rc<PubSub<()>>,
100    on_settings_panel_dimensions_reset: Rc<PubSub<()>>,
101    settings_open: bool,
102
103    /// `removePanel()` completions awaiting their panel's `PanelClosed`
104    /// (the deferred eject — see `ClosePanel`).
105    pending_closes: HashMap<PanelId, Completion>,
106
107    /// Render snapshot of the `Workspace`-owned global filter set (see
108    /// `Workspace::global_filters`); refreshed by `UpdateGlobalFilters` via
109    /// the `filters_changed` subscription. The status-bar chips render from
110    /// this when non-empty.
111    global_filters: Vec<Filter>,
112
113    /// The settings sidebar's geometry state (pane/drawer width overrides,
114    /// divider presize pump, open-state deltas cache) — see
115    /// [`settings::SettingsGeometry`].
116    settings_geometry: SettingsGeometry,
117
118    /// Value-semantic state snapshots (Step 4 scaffold).
119    /// Populated by `UpdateSession` / `UpdateRenderer` / `UpdatePresentation` /
120    /// `UpdateDragDrop` messages dispatched from async engine tasks.
121    session_props: SessionProps,
122    renderer_props: RendererProps,
123    presentation_props: PresentationProps,
124    dragdrop_props: crate::presentation::DragDropProps,
125
126    /// The active panel's in-flight config-run count — a LEVEL-triggered
127    /// snapshot of `Session::config_runs` (RAII-settled; assigned by
128    /// `UpdateInFlight`, re-read on retarget). Threaded to
129    /// `StatusIndicator` as the "updating" spinner.
130    update_count: u32,
131
132    /// Window listeners that toggle the `.shift-active` class on the host
133    /// element while the Shift key is held, making Shift-modified affordances
134    /// (e.g. inactive column add, active column remove, status-bar reset)
135    /// visually discoverable. Stored so the closures outlive `create`.
136    _shift_listeners: ShiftListeners,
137
138    /// `perspective-global-filter` + `perspective-click` listeners on the host
139    /// (master/detail). Both route a master panel's selection state to
140    /// `MasterContribution`. Kept alive here, attached once in `rendered`.
141    _global_filter_listener: Closure<dyn FnMut(web_sys::Event)>,
142    _master_click_listener: Closure<dyn FnMut(web_sys::Event)>,
143    master_listeners_attached: bool,
144}
145
146impl Component for PerspectiveViewer {
147    type Message = PerspectiveViewerMsg;
148    type Properties = PerspectiveViewerProps;
149
150    fn create(ctx: &Context<Self>) -> Self {
151        let elem = ctx.props().elem.clone();
152        let fonts = FontLoaderProps::new(&elem, ctx.link().callback(|()| PreloadFontsUpdate));
153        let empty_session = Session::new();
154        let empty_renderer = Renderer::new(&elem);
155        let active_session = ctx
156            .props()
157            .workspace
158            .active_session()
159            .unwrap_or_else(|| empty_session.clone());
160        let active_renderer = ctx
161            .props()
162            .workspace
163            .active_renderer()
164            .unwrap_or_else(|| empty_renderer.clone());
165        inject_shared_callbacks(ctx);
166        inject_active_callbacks(ctx, &active_session, &active_renderer);
167        let subscriptions = create_shared_subscriptions(ctx);
168        let active_subscriptions =
169            create_active_subscriptions(ctx, &active_session, &active_renderer);
170        let session_props = active_session.to_props();
171        let renderer_props = active_renderer.to_props(None);
172        let presentation_props = ctx.props().presentation.to_props(PtrEqRc::new(vec![]));
173        let on_close_column_settings = ctx.link().callback(|_| OpenColumnSettings {
174            target: None,
175            sender: None,
176            toggle: false,
177        });
178
179        // Kick off an initial async theme fetch so that `available_themes` is
180        // populated even if `theme_config_updated` fires before the PubSub
181        // subscription is registered.
182        {
183            let presentation = ctx.props().presentation.clone();
184            let cb = ctx.link().callback(move |themes: PtrEqRc<Vec<String>>| {
185                UpdatePresentation(Box::new(presentation.to_props(themes)))
186            });
187
188            let presentation = ctx.props().presentation.clone();
189            ApiFuture::spawn(async move {
190                let themes = presentation.get_available_themes().await?;
191                cb.emit(themes);
192                Ok(())
193            });
194        }
195
196        let shift_listeners = install_shift_listeners(elem);
197        let global_filter_listener = wiring::global_filter_listener(ctx);
198        let master_click_listener = wiring::master_click_listener(ctx);
199
200        Self {
201            _subscriptions: subscriptions,
202            _global_filter_listener: global_filter_listener,
203            _master_click_listener: master_click_listener,
204            master_listeners_attached: false,
205            _active_subscriptions: active_subscriptions,
206            _title_subscriptions: subscribe_panel_titles(ctx),
207            active_session,
208            active_renderer,
209            empty_session,
210            empty_renderer,
211            debug_open: false,
212            fonts,
213            on_close_column_settings,
214            on_rendered: Vec::new(),
215            on_resize: Default::default(),
216            on_settings_panel_dimensions_reset: Default::default(),
217            settings_open: false,
218            pending_closes: HashMap::new(),
219            global_filters: Vec::new(),
220            settings_geometry: Default::default(),
221            session_props,
222            renderer_props,
223            presentation_props,
224            dragdrop_props: Default::default(),
225            update_count: 0,
226            _shift_listeners: shift_listeners,
227        }
228    }
229
230    /// The protocol dispatch table: every arm is a one-line call into a domain
231    /// module (see the module doc). Trivial self-describing arms stay inline.
232    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
233        match msg {
234            PreloadFontsUpdate => true,
235            TitlesChanged => true,
236            Resize => {
237                self.on_resize.emit(());
238                false
239            },
240            Reset(all, completion) => {
241                // Element-level reset (symmetric with `save`, which fans out
242                // over all panels): drop the element-level global-filter SET
243                // first — otherwise a detail panel re-applies it right after
244                // the reset — then reset EVERY panel. Master/detail ROLES are
245                // layout state (like the panel arrangement), not panel
246                // config, so they survive. The `Completion`
247                // resolves only after ALL panels' reset runs complete
248                // (invariant I6 — previously it rode the active panel only,
249                // and the other panels' runs were unowned). An "all" reset
250                // FIRST purges the element's derived state (releasing any
251                // derived-bound panel to its root table), so the per-panel
252                // resets run against the surviving base bindings.
253                let workspace = ctx.props().workspace.clone();
254                let presentation = ctx.props().presentation.clone();
255                let origins = workspace.clear_global_filters();
256                clear_master_selections(&workspace, origins);
257                let effect = workspace.effects().guard();
258                let run = async move {
259                    let _effect = effect;
260                    if all {
261                        crate::tasks::purge_derived(&workspace, &presentation).await?;
262                    }
263
264                    let mut tasks = Vec::new();
265                    for (id, panel) in workspace
266                        .panel_ids()
267                        .into_iter()
268                        .filter_map(|id| workspace.panel(&id).map(|p| (id, p)))
269                    {
270                        let _ = id;
271                        drop(broadcast_overlay(&workspace, &panel));
272                        tasks.push(reset_all(
273                            &panel.session,
274                            &panel.renderer,
275                            &presentation,
276                            all,
277                        ));
278                    }
279
280                    futures::future::join_all(tasks)
281                        .await
282                        .into_iter()
283                        .collect::<ApiResult<Vec<_>>>()?;
284                    Ok(())
285                };
286
287                match completion {
288                    Some(completion) => completion.resolve_after(run),
289                    None => spawn_owned("reset", run),
290                }
291
292                false
293            },
294            ResetPanel(id, all, sender) => {
295                // Panel-scoped reset (toolbar / context menu / `resetPanel()`
296                // API): reset ONLY the target panel's config. The
297                // element-level cross-filter overlay is workspace state, not
298                // panel config, so it's deliberately left in place — the
299                // rebuilt view re-applies it via `effective_view_config`,
300                // keeping a detail panel consistent with the rest of the
301                // workspace. A dangling id no-ops (dropping `sender` rejects
302                // the API promise). An "all" reset FIRST purges the
303                // element's derived state (releasing every derived-bound
304                // panel to its root table) — "all" means the same thing from
305                // the status bar gesture and the `resetPanel()` API.
306                let id = id.map(crate::workspace::PanelId::from);
307                if let Some(panel) = ctx.props().workspace.panel_or_active(id.as_ref()) {
308                    let workspace = ctx.props().workspace.clone();
309                    let presentation = ctx.props().presentation.clone();
310                    let effect = workspace.effects().guard();
311                    let run = async move {
312                        let _effect = effect;
313                        if all {
314                            crate::tasks::purge_derived(&workspace, &presentation).await?;
315                        }
316
317                        reset_all(&panel.session, &panel.renderer, &presentation, all).await
318                    };
319
320                    match sender {
321                        Some(completion) => completion.resolve_after(run),
322                        None => spawn_owned("reset-panel", run),
323                    }
324                }
325
326                false
327            },
328
329            // Panel lifecycle (`panels.rs`)
330            LayoutChanged => self.on_layout_changed(ctx),
331            SetActivePanel(id, completion) => self.on_set_active_panel(ctx, id, completion),
332            ClosePanel(id, completion) => self.on_close_panel(ctx, id, completion),
333            PanelClosed(id) => self.on_panel_closed(ctx, id),
334            CommitWorkspaceRestore(id) => self.on_commit_workspace_restore(ctx, id),
335            DuplicatePanel(id) => self.on_duplicate_panel(ctx, id),
336            DerivePanel(id) => self.on_derive_panel(ctx, id),
337            NewPanel(id) => self.on_new_panel(ctx, id),
338            NewPanelFrom { client, table } => self.on_new_panel_from(ctx, client, table),
339
340            // Master/detail cross-filter (`filters.rs`)
341            ToggleMaster(id) => self.on_toggle_master(ctx, id),
342            MasterContribution(panel, selection) => {
343                self.on_master_contribution(ctx, panel, selection)
344            },
345            RemoveGlobalFilter(index) => self.on_remove_global_filter(ctx, index),
346            ClearGlobalFilters => self.on_clear_global_filters(ctx),
347
348            // Settings sidebar + divider pump + column settings (`settings.rs`)
349            ToggleSettingsInit(update, announce, resolve) => {
350                self.on_toggle_settings_init(ctx, update, announce, resolve)
351            },
352            ToggleSettingsComplete(update, resolve) => {
353                self.on_toggle_settings_complete(ctx, update, resolve)
354            },
355            DividerMove(divider, target) => self.on_divider_move(ctx, divider, target),
356            DividerPump(divider) => self.on_divider_pump(ctx, divider),
357            DividerCommit(divider, target) => self.on_divider_commit(ctx, divider, target),
358            DividerFinish(divider) => self.on_divider_finish(ctx, divider),
359            SettingsPanelTabChanged(tab) => self.on_settings_panel_tab_changed(tab),
360            SettingsPanelAutoWidth(w) => self.on_settings_panel_auto_width(ctx, w),
361            OpenColumnSettings {
362                target,
363                sender,
364                toggle,
365            } => self.on_open_column_settings(ctx, target, sender, toggle),
366            ColumnSettingsPanelAutoWidth(w) => self.on_column_settings_panel_auto_width(ctx, w),
367            ToggleColumnSettingsPin => self.on_toggle_column_settings_pin(ctx),
368            ToggleColumnSettingsPinComplete(resolve) => {
369                self.on_toggle_column_settings_pin_complete(resolve)
370            },
371            ColumnSettingsTabChanged(tab) => self.on_column_settings_tab_changed(ctx, tab),
372            ToggleDebug => self.on_toggle_debug(ctx),
373
374            // Value-semantic snapshot plumbing (`snapshots.rs`)
375            UpdateSession(props) => self.on_update_session(ctx, *props),
376            UpdateSessionStats(stats, has_table) => self.on_update_session_stats(stats, has_table),
377            UpdateGlobalFilters => self.on_update_global_filters(ctx),
378            UpdateRenderer(props) => self.on_update_renderer(*props),
379            UpdatePresentation(props) => self.on_update_presentation(ctx, *props),
380            UpdateSettingsOpen(open) => self.on_update_settings_open(open),
381            UpdateIsWorkspace(is_workspace) => self.on_update_is_workspace(is_workspace),
382            UpdateColumnSettings(ocs) => self.on_update_column_settings(ctx, *ocs),
383            UpdateColumnSettingsCommit(resolve) => self.on_update_column_settings_commit(resolve),
384            UpdateDragDrop(props) => self.on_update_dragdrop(*props),
385            UpdateInFlight(count) => self.on_update_in_flight(count),
386        }
387    }
388
389    /// This top-level component is mounted to the Custom Element, so it has no
390    /// API to provide props - but for sanity if needed, just return true on
391    /// change.
392    fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
393        true
394    }
395
396    /// On rendered call notify_resize().  This also triggers any registered
397    /// async callbacks to the Custom Element API.
398    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
399        // Attach the master/detail selection listeners once (the host element
400        // is stable for this component's lifetime).
401        if !self.master_listeners_attached {
402            let _ = ctx.props().elem.add_event_listener_with_callback(
403                "perspective-global-filter",
404                self._global_filter_listener.as_ref().unchecked_ref(),
405            );
406            let _ = ctx.props().elem.add_event_listener_with_callback(
407                "perspective-click",
408                self._master_click_listener.as_ref().unchecked_ref(),
409            );
410            self.master_listeners_attached = true;
411        }
412
413        if !self.on_rendered.is_empty()
414            && matches!(self.fonts.get_status(), FontLoaderStatus::Finished)
415        {
416            for resolve in self.on_rendered.drain(..) {
417                if resolve.send(()).is_err() {
418                    tracing::warn!("Orphan render");
419                }
420            }
421        }
422    }
423
424    fn view(&self, ctx: &Context<Self>) -> Html {
425        self.render(ctx)
426    }
427
428    fn destroy(&mut self, _ctx: &Context<Self>) {}
429}