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