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::in_flight_config_runs` (RAII-settled;
128    /// assigned by `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).
250                let workspace = &ctx.props().workspace;
251                let origins = workspace.clear_global_filters();
252                clear_master_selections(workspace, origins);
253                let mut tasks = Vec::new();
254                for (id, panel) in workspace
255                    .panel_ids()
256                    .into_iter()
257                    .filter_map(|id| workspace.panel(&id).map(|p| (id, p)))
258                {
259                    stamp_global_overlay(workspace, &id, &panel.session);
260                    tasks.push(reset_all(
261                        &panel.session,
262                        &panel.renderer,
263                        &ctx.props().presentation,
264                        all,
265                    ));
266                }
267
268                let run = async move {
269                    futures::future::join_all(tasks)
270                        .await
271                        .into_iter()
272                        .collect::<ApiResult<Vec<_>>>()?;
273                    Ok(())
274                };
275
276                match completion {
277                    Some(completion) => completion.resolve_after(run),
278                    None => spawn_owned("reset", run),
279                }
280
281                false
282            },
283            ResetPanel(id, all, sender) => {
284                // Panel-scoped reset (toolbar / context menu / `resetPanel()`
285                // API): reset ONLY the target panel's config. The
286                // element-level cross-filter overlay is workspace state, not
287                // panel config, so it's deliberately left in place — the
288                // rebuilt view re-applies it via `effective_view_config`,
289                // keeping a detail panel consistent with the rest of the
290                // workspace. A dangling id no-ops (dropping `sender` rejects
291                // the API promise).
292                let id = id.map(crate::workspace::PanelId::from);
293                if let Some(panel) = ctx.props().workspace.panel_or_active(id.as_ref()) {
294                    let run = reset_all(
295                        &panel.session,
296                        &panel.renderer,
297                        &ctx.props().presentation,
298                        all,
299                    );
300
301                    match sender {
302                        Some(completion) => completion.resolve_after(run),
303                        None => spawn_owned("reset-panel", run),
304                    }
305                }
306
307                false
308            },
309
310            // Panel lifecycle (`panels.rs`)
311            LayoutChanged => self.on_layout_changed(ctx),
312            SetActivePanel(id, completion) => self.on_set_active_panel(ctx, id, completion),
313            ClosePanel(id, completion) => self.on_close_panel(ctx, id, completion),
314            PanelClosed(id) => self.on_panel_closed(ctx, id),
315            CommitWorkspaceRestore(id) => self.on_commit_workspace_restore(ctx, id),
316            DuplicatePanel(id) => self.on_duplicate_panel(ctx, id),
317            NewPanel(id) => self.on_new_panel(ctx, id),
318            NewPanelFrom { client, table } => self.on_new_panel_from(ctx, client, table),
319
320            // Master/detail cross-filter (`filters.rs`)
321            ToggleMaster(id) => self.on_toggle_master(ctx, id),
322            MasterContribution(panel, selection) => {
323                self.on_master_contribution(ctx, panel, selection)
324            },
325            RemoveGlobalFilter(index) => self.on_remove_global_filter(ctx, index),
326            ClearGlobalFilters => self.on_clear_global_filters(ctx),
327
328            // Settings sidebar + divider pump + column settings (`settings.rs`)
329            ToggleSettingsInit(update, announce, resolve) => {
330                self.on_toggle_settings_init(ctx, update, announce, resolve)
331            },
332            ToggleSettingsComplete(update, resolve) => {
333                self.on_toggle_settings_complete(ctx, update, resolve)
334            },
335            DividerMove(divider, target) => self.on_divider_move(ctx, divider, target),
336            DividerPump(divider) => self.on_divider_pump(ctx, divider),
337            DividerCommit(divider, target) => self.on_divider_commit(ctx, divider, target),
338            DividerFinish(divider) => self.on_divider_finish(ctx, divider),
339            SettingsPanelTabChanged(tab) => self.on_settings_panel_tab_changed(tab),
340            SettingsPanelAutoWidth(w) => self.on_settings_panel_auto_width(ctx, w),
341            OpenColumnSettings {
342                target,
343                sender,
344                toggle,
345            } => self.on_open_column_settings(ctx, target, sender, toggle),
346            ColumnSettingsPanelAutoWidth(w) => self.on_column_settings_panel_auto_width(ctx, w),
347            ToggleColumnSettingsPin => self.on_toggle_column_settings_pin(ctx),
348            ToggleColumnSettingsPinComplete(resolve) => {
349                self.on_toggle_column_settings_pin_complete(resolve)
350            },
351            ColumnSettingsTabChanged(tab) => self.on_column_settings_tab_changed(ctx, tab),
352            ToggleDebug => self.on_toggle_debug(ctx),
353
354            // Value-semantic snapshot plumbing (`snapshots.rs`)
355            UpdateSession(props) => self.on_update_session(ctx, *props),
356            UpdateSessionStats(stats, has_table) => self.on_update_session_stats(stats, has_table),
357            UpdateGlobalFilters => self.on_update_global_filters(ctx),
358            UpdateRenderer(props) => self.on_update_renderer(*props),
359            UpdatePresentation(props) => self.on_update_presentation(ctx, *props),
360            UpdateSettingsOpen(open) => self.on_update_settings_open(open),
361            UpdateIsWorkspace(is_workspace) => self.on_update_is_workspace(is_workspace),
362            UpdateColumnSettings(ocs) => self.on_update_column_settings(ctx, *ocs),
363            UpdateColumnSettingsCommit(resolve) => self.on_update_column_settings_commit(resolve),
364            UpdateDragDrop(props) => self.on_update_dragdrop(*props),
365            UpdateInFlight(count) => self.on_update_in_flight(count),
366        }
367    }
368
369    /// This top-level component is mounted to the Custom Element, so it has no
370    /// API to provide props - but for sanity if needed, just return true on
371    /// change.
372    fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
373        true
374    }
375
376    /// On rendered call notify_resize().  This also triggers any registered
377    /// async callbacks to the Custom Element API.
378    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
379        // Attach the master/detail selection listeners once (the host element
380        // is stable for this component's lifetime).
381        if !self.master_listeners_attached {
382            let _ = ctx.props().elem.add_event_listener_with_callback(
383                "perspective-global-filter",
384                self._global_filter_listener.as_ref().unchecked_ref(),
385            );
386            let _ = ctx.props().elem.add_event_listener_with_callback(
387                "perspective-click",
388                self._master_click_listener.as_ref().unchecked_ref(),
389            );
390            self.master_listeners_attached = true;
391        }
392
393        if !self.on_rendered.is_empty()
394            && matches!(self.fonts.get_status(), FontLoaderStatus::Finished)
395        {
396            for resolve in self.on_rendered.drain(..) {
397                if resolve.send(()).is_err() {
398                    tracing::warn!("Orphan render");
399                }
400            }
401        }
402    }
403
404    fn view(&self, ctx: &Context<Self>) -> Html {
405        self.render(ctx)
406    }
407
408    fn destroy(&mut self, _ctx: &Context<Self>) {}
409}