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
13use std::rc::Rc;
14
15use futures::channel::oneshot::*;
16use perspective_js::utils::*;
17use wasm_bindgen::JsCast;
18use wasm_bindgen::prelude::*;
19use web_sys::{FocusEvent, KeyboardEvent};
20use yew::prelude::*;
21
22use super::containers::split_panel::SplitPanel;
23use super::font_loader::{FontLoader, FontLoaderProps, FontLoaderStatus};
24use super::style::StyleProvider;
25use crate::components::column_settings_sidebar::ColumnSettingsPanel;
26use crate::components::main_panel::MainPanel;
27use crate::components::settings_panel::{SelectedTab, SettingsPanel};
28use crate::config::*;
29use crate::js::JsPerspectiveViewerPlugin;
30use crate::presentation::{
31    ColumnLocator, ColumnSettingsTab, DragDropProps, Presentation, PresentationProps,
32};
33use crate::queries::*;
34use crate::renderer::{RendererProps, *};
35use crate::session::{SessionProps, *};
36use crate::tasks::*;
37use crate::utils::*;
38
39#[derive(Clone, Properties)]
40pub struct PerspectiveViewerProps {
41    /// The light DOM element this component will render to.
42    pub elem: web_sys::HtmlElement,
43
44    /// State
45    pub session: Session,
46    pub renderer: Renderer,
47    pub presentation: Presentation,
48}
49
50impl PartialEq for PerspectiveViewerProps {
51    fn eq(&self, _rhs: &Self) -> bool {
52        false
53    }
54}
55
56#[derive(Debug)]
57pub enum PerspectiveViewerMsg {
58    ColumnSettingsPanelSizeUpdate(Option<i32>),
59    ColumnSettingsTabChanged(ColumnSettingsTab),
60    OpenColumnSettings {
61        locator: Option<ColumnLocator>,
62        sender: Option<Sender<()>>,
63        toggle: bool,
64    },
65    PreloadFontsUpdate,
66    Reset(bool, Option<Sender<()>>),
67    Resize,
68    SettingsPanelSizeUpdate(Option<i32>),
69    SettingsPanelTabChanged(SelectedTab),
70    SettingsPanelAutoWidth(f64),
71    ToggleDebug,
72    ToggleSettingsComplete(SettingsUpdate, Sender<()>),
73    ToggleSettingsInit(Option<SettingsUpdate>, Option<Sender<ApiResult<JsValue>>>),
74    UpdateSession(Box<SessionProps>),
75    UpdateRenderer(Box<RendererProps>),
76    UpdatePresentation(Box<PresentationProps>),
77
78    /// Update only `is_settings_open` in the presentation snapshot without
79    /// touching `available_themes` (which requires async data).
80    UpdateSettingsOpen(bool),
81    UpdateIsWorkspace(bool),
82
83    /// Update only `open_column_settings` in the presentation snapshot.
84    UpdateColumnSettings(Box<crate::presentation::OpenColumnSettings>),
85    UpdateDragDrop(Box<DragDropProps>),
86
87    /// Update only stats-related fields of `session_props` without touching
88    /// `config`.  This prevents `stats_changed` events (e.g. from `reset()`)
89    /// from propagating a freshly-cleared config to the column selector.
90    UpdateSessionStats(Option<ViewStats>, Option<TableLoadState>),
91
92    /// Increment/decrement the in-flight render counter threaded to
93    /// `StatusIndicator` so it can show the "updating" spinner.
94    IncrementUpdateCount,
95    DecrementUpdateCount,
96}
97
98use PerspectiveViewerMsg::*;
99
100pub struct PerspectiveViewer {
101    _subscriptions: Vec<Subscription>,
102    column_settings_panel_width_override: Option<i32>,
103    debug_open: bool,
104    fonts: FontLoaderProps,
105    on_close_column_settings: Callback<()>,
106    on_rendered: Option<Sender<()>>,
107    on_resize: Rc<PubSub<()>>,
108    on_settings_panel_dimensions_reset: Rc<PubSub<()>>,
109    settings_open: bool,
110    settings_panel_width_override: Option<i32>,
111    settings_panel_selected_tab: SelectedTab,
112    settings_panel_auto_width: f64,
113
114    /// Value-semantic state snapshots (Step 4 scaffold).
115    /// Populated by `UpdateSession` / `UpdateRenderer` / `UpdatePresentation` /
116    /// `UpdateDragDrop` messages dispatched from async engine tasks.
117    session_props: SessionProps,
118    renderer_props: RendererProps,
119    presentation_props: PresentationProps,
120    dragdrop_props: DragDropProps,
121
122    /// Counts in-flight renders (incremented on `view_config_changed`,
123    /// decremented on `view_created`). Threaded to `StatusIndicator`.
124    update_count: u32,
125
126    /// Window listeners that toggle the `.shift-active` class on the host
127    /// element while the Shift key is held, making Shift-modified affordances
128    /// (e.g. inactive column add, active column remove, status-bar reset)
129    /// visually discoverable. Stored so the closures outlive `create`.
130    _shift_listeners: ShiftListeners,
131}
132
133struct ShiftListeners {
134    elem: web_sys::HtmlElement,
135    keydown: Closure<dyn FnMut(KeyboardEvent)>,
136    keyup: Closure<dyn FnMut(KeyboardEvent)>,
137    blur: Closure<dyn FnMut(FocusEvent)>,
138}
139
140impl Drop for ShiftListeners {
141    fn drop(&mut self) {
142        let win = global::window();
143        let _ = win
144            .remove_event_listener_with_callback("keydown", self.keydown.as_ref().unchecked_ref());
145        let _ =
146            win.remove_event_listener_with_callback("keyup", self.keyup.as_ref().unchecked_ref());
147        let _ = win.remove_event_listener_with_callback("blur", self.blur.as_ref().unchecked_ref());
148        let _ = self.elem.class_list().remove_1("shift-active");
149    }
150}
151
152fn install_shift_listeners(elem: web_sys::HtmlElement) -> ShiftListeners {
153    let keydown = {
154        let elem = elem.clone();
155        Closure::wrap(Box::new(move |event: KeyboardEvent| {
156            if event.key() == "Shift" {
157                let _ = elem.class_list().add_1("shift-active");
158            }
159        }) as Box<dyn FnMut(KeyboardEvent)>)
160    };
161
162    let keyup = {
163        let elem = elem.clone();
164        Closure::wrap(Box::new(move |event: KeyboardEvent| {
165            if event.key() == "Shift" {
166                let _ = elem.class_list().remove_1("shift-active");
167            }
168        }) as Box<dyn FnMut(KeyboardEvent)>)
169    };
170
171    let blur = {
172        let elem = elem.clone();
173        Closure::wrap(Box::new(move |_: FocusEvent| {
174            let _ = elem.class_list().remove_1("shift-active");
175        }) as Box<dyn FnMut(FocusEvent)>)
176    };
177
178    let win = global::window();
179    let _ = win.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref());
180    let _ = win.add_event_listener_with_callback("keyup", keyup.as_ref().unchecked_ref());
181    let _ = win.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref());
182
183    ShiftListeners {
184        elem,
185        keydown,
186        keyup,
187        blur,
188    }
189}
190
191impl Component for PerspectiveViewer {
192    type Message = PerspectiveViewerMsg;
193    type Properties = PerspectiveViewerProps;
194
195    fn create(ctx: &Context<Self>) -> Self {
196        let elem = ctx.props().elem.clone();
197        let fonts = FontLoaderProps::new(&elem, ctx.link().callback(|()| PreloadFontsUpdate));
198        inject_engine_callbacks(ctx);
199        let subscriptions = create_subscriptions(ctx);
200        let session_props = ctx.props().session.to_props();
201        let renderer_props = ctx.props().renderer.to_props(None);
202        let presentation_props = ctx.props().presentation.to_props(PtrEqRc::new(vec![]));
203
204        // Memoized callback for column settings drawer
205        let on_close_column_settings = ctx.link().callback(|_| OpenColumnSettings {
206            locator: None,
207            sender: None,
208            toggle: false,
209        });
210
211        // Kick off an initial async theme fetch so that `available_themes` is
212        // populated even if `theme_config_updated` fires before the PubSub
213        // subscription is registered.
214        {
215            let presentation = ctx.props().presentation.clone();
216            let cb = ctx.link().callback(move |themes: PtrEqRc<Vec<String>>| {
217                UpdatePresentation(Box::new(presentation.to_props(themes)))
218            });
219
220            let presentation = ctx.props().presentation.clone();
221            ApiFuture::spawn(async move {
222                let themes = presentation.get_available_themes().await?;
223                cb.emit(themes);
224                Ok(())
225            });
226        }
227
228        let shift_listeners = install_shift_listeners(elem);
229
230        Self {
231            _subscriptions: subscriptions,
232            column_settings_panel_width_override: None,
233            debug_open: false,
234            fonts,
235            on_close_column_settings,
236            on_rendered: None,
237            on_resize: Default::default(),
238            on_settings_panel_dimensions_reset: Default::default(),
239            settings_open: false,
240            settings_panel_width_override: None,
241            settings_panel_selected_tab: SelectedTab::default(),
242            settings_panel_auto_width: 0.0,
243            session_props,
244            renderer_props,
245            presentation_props,
246            dragdrop_props: DragDropProps::default(),
247            update_count: 0,
248            _shift_listeners: shift_listeners,
249        }
250    }
251
252    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
253        match msg {
254            PreloadFontsUpdate => true,
255            Resize => {
256                self.on_resize.emit(());
257                false
258            },
259            Reset(all, sender) => {
260                reset_all(
261                    &ctx.props().session,
262                    &ctx.props().renderer,
263                    &ctx.props().presentation,
264                    all,
265                    sender,
266                );
267                false
268            },
269            ToggleSettingsInit(Some(SettingsUpdate::Missing), None) => false,
270            ToggleSettingsInit(Some(SettingsUpdate::Missing), Some(resolve)) => {
271                resolve.send(Ok(JsValue::UNDEFINED)).unwrap();
272                false
273            },
274            ToggleSettingsInit(Some(SettingsUpdate::SetDefault), resolve) => {
275                self.init_toggle_settings_task(ctx, Some(false), resolve);
276                false
277            },
278            ToggleSettingsInit(Some(SettingsUpdate::Update(force)), resolve) => {
279                self.init_toggle_settings_task(ctx, Some(force), resolve);
280                false
281            },
282            ToggleSettingsInit(None, resolve) => {
283                self.init_toggle_settings_task(ctx, None, resolve);
284                false
285            },
286            ToggleSettingsComplete(SettingsUpdate::SetDefault, resolve) if self.settings_open => {
287                ctx.props().presentation.set_open_column_settings(None);
288                self.settings_open = false;
289                self.on_rendered = Some(resolve);
290                true
291            },
292            ToggleSettingsComplete(SettingsUpdate::Update(force), resolve)
293                if force != self.settings_open =>
294            {
295                ctx.props().presentation.set_open_column_settings(None);
296                self.settings_open = force;
297                self.on_rendered = Some(resolve);
298                true
299            },
300            ToggleSettingsComplete(_, resolve)
301                if matches!(self.fonts.get_status(), FontLoaderStatus::Finished) =>
302            {
303                if let Err(e) = resolve.send(()) {
304                    tracing::error!("toggle settings failed {:?}", e);
305                }
306
307                false
308            },
309            ToggleSettingsComplete(_, resolve) => {
310                ctx.props().presentation.set_open_column_settings(None);
311                self.on_rendered = Some(resolve);
312                true
313            },
314            OpenColumnSettings {
315                locator,
316                sender,
317                toggle,
318            } => {
319                let mut open_column_settings = ctx.props().presentation.get_open_column_settings();
320                if locator == open_column_settings.locator {
321                    if toggle {
322                        ctx.props().presentation.set_open_column_settings(None);
323                    }
324                } else {
325                    open_column_settings.locator.clone_from(&locator);
326                    open_column_settings.tab =
327                        if matches!(locator, Some(ColumnLocator::NewExpression)) {
328                            Some(ColumnSettingsTab::Attributes)
329                        } else {
330                            locator.as_ref().and_then(|x| {
331                                x.name().map(|x| {
332                                    if self.session_props.is_column_active(x) {
333                                        ColumnSettingsTab::Style
334                                    } else {
335                                        ColumnSettingsTab::Attributes
336                                    }
337                                })
338                            })
339                        };
340
341                    ctx.props()
342                        .presentation
343                        .set_open_column_settings(Some(open_column_settings));
344
345                    if locator.is_some() {
346                        self.settings_panel_selected_tab = SelectedTab::Query;
347                    }
348                }
349
350                if let Some(sender) = sender {
351                    sender.send(()).unwrap();
352                }
353
354                true
355            },
356            SettingsPanelSizeUpdate(Some(x)) => {
357                self.settings_panel_width_override = Some(x);
358                false
359            },
360            SettingsPanelSizeUpdate(None) => {
361                self.settings_panel_width_override = None;
362                self.settings_panel_auto_width = 0.0;
363                self.on_settings_panel_dimensions_reset.emit(());
364                true
365            },
366            SettingsPanelTabChanged(tab) => {
367                let changed = tab != self.settings_panel_selected_tab;
368                self.settings_panel_selected_tab = tab;
369                changed
370            },
371            SettingsPanelAutoWidth(w) => {
372                if w > self.settings_panel_auto_width {
373                    self.settings_panel_auto_width = w;
374                    true
375                } else {
376                    false
377                }
378            },
379            ColumnSettingsPanelSizeUpdate(Some(x)) => {
380                self.column_settings_panel_width_override = Some(x);
381                false
382            },
383            ColumnSettingsPanelSizeUpdate(None) => {
384                self.column_settings_panel_width_override = None;
385                false
386            },
387            ColumnSettingsTabChanged(tab) => {
388                let mut open_column_settings = ctx.props().presentation.get_open_column_settings();
389                open_column_settings.tab.clone_from(&Some(tab));
390                ctx.props()
391                    .presentation
392                    .set_open_column_settings(Some(open_column_settings));
393                true
394            },
395            ToggleDebug => {
396                self.debug_open = !self.debug_open;
397                clone!(ctx.props().renderer, ctx.props().session);
398                ApiFuture::spawn(async move {
399                    renderer.draw(session.validate().await?.create_view()).await
400                });
401
402                true
403            },
404            UpdateSession(props) => {
405                let changed = *props != self.session_props;
406                self.session_props = *props;
407                changed
408            },
409            UpdateSessionStats(stats, has_table) => {
410                let changed =
411                    stats != self.session_props.stats || has_table != self.session_props.has_table;
412                self.session_props.stats = stats;
413                self.session_props.has_table = has_table;
414                changed
415            },
416            UpdateRenderer(props) => {
417                let changed = *props != self.renderer_props;
418                self.renderer_props = *props;
419                changed
420            },
421            UpdatePresentation(props) => {
422                let changed = *props != self.presentation_props;
423                self.presentation_props = *props;
424                changed
425            },
426            UpdateSettingsOpen(open) => {
427                let changed = open != self.presentation_props.is_settings_open;
428                self.presentation_props.is_settings_open = open;
429                changed
430            },
431            UpdateIsWorkspace(is_workspace) => {
432                let changed = is_workspace != self.presentation_props.is_workspace;
433                self.presentation_props.is_workspace = is_workspace;
434                changed
435            },
436            UpdateColumnSettings(ocs) => {
437                let changed = *ocs != self.presentation_props.open_column_settings;
438                self.presentation_props.open_column_settings = *ocs;
439                changed
440            },
441            UpdateDragDrop(props) => {
442                let changed = *props != self.dragdrop_props;
443                self.dragdrop_props = *props;
444                changed
445            },
446            IncrementUpdateCount => {
447                self.update_count = self.update_count.saturating_add(1);
448                true
449            },
450            DecrementUpdateCount => {
451                self.update_count = self.update_count.saturating_sub(1);
452                true
453            },
454        }
455    }
456
457    /// This top-level component is mounted to the Custom Element, so it has no
458    /// API to provide props - but for sanity if needed, just return true on
459    /// change.
460    fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
461        true
462    }
463
464    /// On rendered call notify_resize().  This also triggers any registered
465    /// async callbacks to the Custom Element API.
466    fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
467        if self.on_rendered.is_some()
468            && matches!(self.fonts.get_status(), FontLoaderStatus::Finished)
469            && self.on_rendered.take().unwrap().send(()).is_err()
470        {
471            tracing::warn!("Orphan render");
472        }
473    }
474
475    fn view(&self, ctx: &Context<Self>) -> Html {
476        let Self::Properties {
477            presentation,
478            renderer,
479            session,
480            ..
481        } = ctx.props();
482
483        let is_settings_open = self.settings_open
484            && matches!(self.session_props.has_table, Some(TableLoadState::Loaded));
485
486        let mut class = classes!();
487        if !is_settings_open {
488            class.push("settings-closed");
489        }
490
491        if self.session_props.title.is_some() {
492            class.push("titled");
493        }
494
495        let on_open_expr_panel = ctx.link().callback(|c| OpenColumnSettings {
496            locator: c,
497            sender: None,
498            toggle: true,
499        });
500
501        let on_split_panel_resize = ctx
502            .link()
503            .callback(|(x, _)| SettingsPanelSizeUpdate(Some(x)));
504
505        let on_column_settings_panel_resize = ctx
506            .link()
507            .callback(|(x, _)| ColumnSettingsPanelSizeUpdate(Some(x)));
508
509        let on_close_settings = ctx.link().callback(|()| ToggleSettingsInit(None, None));
510        let on_debug = ctx.link().callback(|_| ToggleDebug);
511        let selected_column = get_current_column_locator(
512            &self.presentation_props.open_column_settings,
513            &ctx.props().renderer,
514            &self.session_props.config,
515            &self.session_props.metadata,
516        );
517
518        let selected_tab = self.presentation_props.open_column_settings.tab;
519        let plugin_name = self.renderer_props.plugin_name.clone();
520        let available_plugins = self.renderer_props.available_plugins.clone();
521        let has_table = self.session_props.has_table.clone();
522        let named_column_count = self.renderer_props.config.config_column_names.len();
523
524        let view_config = self.session_props.config.clone();
525        let drag_column = self.dragdrop_props.column.clone();
526        let metadata = self.session_props.metadata.clone();
527        let on_select_tab = ctx.link().callback(SettingsPanelTabChanged);
528        let on_auto_width = ctx.link().callback(SettingsPanelAutoWidth);
529        let settings_panel = html! {
530            if is_settings_open {
531                <SettingsPanel
532                    on_close={on_close_settings}
533                    on_resize={&self.on_resize}
534                    on_select_column={on_open_expr_panel}
535                    is_debug={self.debug_open}
536                    {on_debug}
537                    {plugin_name}
538                    {available_plugins}
539                    {has_table}
540                    {named_column_count}
541                    {view_config}
542                    plugin_config={self.renderer_props.plugin_config.clone()}
543                    {drag_column}
544                    metadata={metadata.clone()}
545                    open_column_settings={self.presentation_props.open_column_settings.clone()}
546                    selected_theme={self.presentation_props.selected_theme.clone()}
547                    selected_tab={self.settings_panel_selected_tab}
548                    auto_width={self.settings_panel_auto_width}
549                    on_dimensions_reset={&self.on_settings_panel_dimensions_reset}
550                    {on_select_tab}
551                    {on_auto_width}
552                    {presentation}
553                    {renderer}
554                    {session}
555                />
556            }
557        };
558
559        let on_settings = ctx.link().callback(|()| ToggleSettingsInit(None, None));
560        let on_select_tab = ctx.link().callback(ColumnSettingsTabChanged);
561        let column_settings_panel = html! {
562            if let Some(selected_column) = selected_column {
563                <SplitPanel
564                    id="modal_panel"
565                    reverse=true
566                    initial_size={self.column_settings_panel_width_override}
567                    on_reset={ctx.link().callback(|_| ColumnSettingsPanelSizeUpdate(None))}
568                    on_resize={on_column_settings_panel_resize}
569                >
570                    <ColumnSettingsPanel
571                        {selected_column}
572                        {selected_tab}
573                        on_close={self.on_close_column_settings.clone()}
574                        width_override={self.column_settings_panel_width_override}
575                        {on_select_tab}
576                        plugin_name={self.renderer_props.plugin_name.clone()}
577                        {metadata}
578                        view_config={self.session_props.config.clone()}
579                        column_stats={self.session_props.column_stats.clone()}
580                        selected_theme={self.presentation_props.selected_theme.clone()}
581                        {presentation}
582                        {renderer}
583                        {session}
584                    />
585                    <></>
586                </SplitPanel>
587            }
588        };
589
590        let on_reset = ctx.link().callback(|all| Reset(all, None));
591        let is_settings_open = self.settings_open
592            && matches!(self.session_props.has_table, Some(TableLoadState::Loaded));
593        let main_panel = html! {
594            <MainPanel
595                {on_settings}
596                {on_reset}
597                session_props={self.session_props.clone()}
598                renderer_props={self.renderer_props.clone()}
599                presentation_props={self.presentation_props.clone()}
600                {is_settings_open}
601                update_count={self.update_count}
602                {presentation}
603                {renderer}
604                {session}
605            />
606        };
607
608        html! {
609            <StyleProvider root={ctx.props().elem.clone()}>
610                <div id="component_container">
611                    if is_settings_open {
612                        <SplitPanel
613                            id="app_panel"
614                            reverse=true
615                            skip_empty=true
616                            initial_size={self.settings_panel_width_override}
617                            on_reset={ctx.link().callback(|_| SettingsPanelSizeUpdate(None))}
618                            on_resize={{
619                                let size_cb = on_split_panel_resize.clone();
620                                let resize_cb = resize_callback(&ctx.props().session, &ctx.props().renderer);
621                                move |x| {
622                                    size_cb.emit(x);
623                                    resize_cb.emit(());
624                                }
625                            }}
626                            on_resize_finished={resize_callback(&ctx.props().session, &ctx.props().renderer)}
627                        >
628                            { settings_panel }
629                            <div id="main_column_container">
630                                { main_panel }
631                                { column_settings_panel }
632                            </div>
633                        </SplitPanel>
634                    } else {
635                        <div id="main_column_container">
636                            { main_panel }
637                            { column_settings_panel }
638                        </div>
639                    }
640                </div>
641                <FontLoader ..self.fonts.clone() />
642            </StyleProvider>
643        }
644    }
645
646    fn destroy(&mut self, _ctx: &Context<Self>) {}
647}
648
649impl PerspectiveViewer {
650    /// Toggle the settings, or force the settings panel either open (true) or
651    /// closed (false) explicitly.  In order to reduce apparent
652    /// screen-shear, `toggle_settings()` uses a somewhat complex render
653    /// order:  it first resize the plugin's `<div>` without moving it,
654    /// using `overflow: hidden` to hide the extra draw area;  then,
655    /// after the _async_ drawing of the plugin is complete, it will send a
656    /// message to complete the toggle action and re-render the element with
657    /// the settings removed.
658    ///
659    /// # Arguments
660    /// * `force` - Whether to explicitly set the settings panel state to
661    ///   Open/Close (`Some(true)`/`Some(false)`), or to just toggle the current
662    ///   state (`None`).
663    fn init_toggle_settings_task(
664        &mut self,
665        ctx: &Context<Self>,
666        force: Option<bool>,
667        sender: Option<Sender<ApiResult<JsValue>>>,
668    ) {
669        let is_open = ctx.props().presentation.is_settings_open();
670        ctx.props().presentation.set_settings_before_open(!is_open);
671        match force {
672            Some(force) if is_open == force => {
673                if let Some(sender) = sender {
674                    sender.send(Ok(JsValue::UNDEFINED)).unwrap();
675                }
676            },
677            Some(_) | None => {
678                let force = !is_open;
679                let callback = ctx.link().callback(move |resolve| {
680                    let update = SettingsUpdate::Update(force);
681                    ToggleSettingsComplete(update, resolve)
682                });
683
684                clone!(
685                    ctx.props().renderer,
686                    ctx.props().session,
687                    ctx.props().presentation
688                );
689
690                ApiFuture::spawn(async move {
691                    let result = if session.js_get_table().is_some() {
692                        renderer
693                            .presize(force, {
694                                let (sender, receiver) = channel::<()>();
695                                async move {
696                                    callback.emit(sender);
697                                    presentation.set_settings_open(!is_open);
698                                    Ok(receiver.await?)
699                                }
700                            })
701                            .await
702                    } else {
703                        let (sender, receiver) = channel::<()>();
704                        callback.emit(sender);
705                        presentation.set_settings_open(!is_open);
706                        receiver.await?;
707                        Ok(JsValue::UNDEFINED)
708                    };
709
710                    if let Some(sender) = sender {
711                        let msg = result.ignore_view_delete();
712                        sender
713                            .send(msg.map(|x| x.unwrap_or(JsValue::UNDEFINED)))
714                            .into_apierror()?;
715                    };
716
717                    Ok(JsValue::undefined())
718                });
719            },
720        };
721    }
722}
723
724/// Subscribe to PubSub events that still have non-root subscribers and
725/// therefore cannot yet be replaced with direct callbacks.
726fn create_subscriptions(ctx: &Context<PerspectiveViewer>) -> Vec<Subscription> {
727    let session_props_sub = {
728        let session = ctx.props().session.clone();
729        let cb = ctx
730            .link()
731            .callback(move |_: ()| UpdateSession(Box::new(session.to_props())));
732
733        let s = &ctx.props().session;
734        let sub1 = s.table_loaded.add_notify_listener(&cb);
735        let sub2 = s.table_unloaded.add_notify_listener(&cb);
736        let sub3 = s.view_created.add_notify_listener(&cb);
737        let sub4 = s.view_config_changed.add_notify_listener(&cb);
738        let sub5 = s.title_changed.add_notify_listener(&cb);
739        let sub6 = s
740            .view_config_changed
741            .add_listener(ctx.link().callback(|_| IncrementUpdateCount));
742
743        let sub7 = s
744            .view_created
745            .add_listener(ctx.link().callback(|_| DecrementUpdateCount));
746
747        // Stats fetch resolution (populates session.column_stats) triggers
748        // a fresh `SessionProps` so `column_stats` reaches downstream
749        // components and the StyleTab re-queries the schema with the
750        // new value.
751        let sub8 = s.column_stats_changed.add_notify_listener(&cb);
752
753        vec![sub1, sub2, sub3, sub4, sub5, sub6, sub7, sub8]
754    };
755
756    let renderer_props_sub = {
757        let renderer = ctx.props().renderer.clone();
758        let cb_plugin = ctx.link().callback({
759            let renderer = renderer.clone();
760            move |_: JsPerspectiveViewerPlugin| UpdateRenderer(Box::new(renderer.to_props(None)))
761        });
762
763        // Re-snapshot RendererProps when the plugin_config bucket
764        // changes (in-tab edit via `send_plugin_config`, JSON paste via
765        // `restore_and_render`, full clear via `reset_all` with
766        // `all=true`). Without this, `RendererProps.plugin_config`
767        // would stay frozen at its construct-time value and `PluginTab`
768        // would render stale.
769        let cb_plugin_config = ctx.link().callback({
770            let renderer = renderer.clone();
771            move |_: serde_json::Map<String, serde_json::Value>| {
772                UpdateRenderer(Box::new(renderer.to_props(None)))
773            }
774        });
775
776        let sub1 = ctx.props().renderer.plugin_changed.add_listener(cb_plugin);
777        let sub2 = ctx
778            .props()
779            .renderer
780            .plugin_config_changed
781            .add_listener(cb_plugin_config);
782
783        vec![sub1, sub2]
784    };
785
786    let presentation_props_sub = {
787        let presentation = ctx.props().presentation.clone();
788        let cb_settings = ctx.link().callback(UpdateSettingsOpen);
789        let cb_theme = {
790            let pres = presentation.clone();
791            ctx.link()
792                .callback(move |(themes, _): (PtrEqRc<Vec<String>>, _)| {
793                    UpdatePresentation(Box::new(pres.to_props(themes)))
794                })
795        };
796
797        let cb_column_settings = {
798            let pres = presentation.clone();
799            ctx.link().callback(move |_: (bool, Option<String>)| {
800                UpdateColumnSettings(Box::new(pres.get_open_column_settings()))
801            })
802        };
803
804        let sub1 = presentation.settings_open_changed.add_listener(cb_settings);
805        let sub2 = presentation.theme_config_updated.add_listener(cb_theme);
806        let sub3 = presentation
807            .column_settings_open_changed
808            .add_listener(cb_column_settings);
809
810        vec![sub1, sub2, sub3]
811    };
812
813    let dragdrop_props_sub = {
814        let cb_clear = ctx.link().callback(|_: ()| UpdateDragDrop(Box::default()));
815        let sub1 = ctx
816            .props()
817            .presentation
818            .drop_received
819            .add_notify_listener(&cb_clear);
820
821        vec![sub1]
822    };
823
824    let mut subscriptions = Vec::new();
825    subscriptions.extend(session_props_sub);
826    subscriptions.extend(renderer_props_sub);
827    subscriptions.extend(presentation_props_sub);
828    subscriptions.extend(dragdrop_props_sub);
829    subscriptions
830}
831
832/// Inject direct callbacks into the engine handles, replacing PubSub fields
833/// that were exclusively consumed by the root component.
834fn inject_engine_callbacks(ctx: &Context<PerspectiveViewer>) {
835    // Session: on_stats_changed
836    {
837        let session = ctx.props().session.clone();
838        let cb = ctx.link().callback(move |_: ()| {
839            UpdateSessionStats(session.get_table_stats(), session.has_table())
840        });
841
842        *ctx.props().session.on_stats_changed.borrow_mut() = Some(cb);
843    }
844
845    // Session: on_table_errored
846    {
847        let session = ctx.props().session.clone();
848        let cb = ctx
849            .link()
850            .callback(move |_: ()| UpdateSession(Box::new(session.to_props())));
851
852        *ctx.props().session.on_table_errored.borrow_mut() = Some(cb);
853    }
854
855    // Renderer: on_render_limits_changed (combines UpdateRenderer + column
856    // locator recheck that were previously two separate PubSub subscriptions).
857    {
858        clone!(
859            ctx.props().presentation,
860            ctx.props().renderer,
861            ctx.props().session
862        );
863
864        let cb = ctx.link().batch_callback(move |limits: RenderLimits| {
865            let mut msgs = vec![UpdateRenderer(Box::new(renderer.to_props(Some(limits))))];
866            if !limits.is_update {
867                let locator = get_current_column_locator(
868                    &presentation.get_open_column_settings(),
869                    &renderer,
870                    &session.get_view_config(),
871                    &session.metadata(),
872                );
873
874                msgs.push(OpenColumnSettings {
875                    locator,
876                    sender: None,
877                    toggle: false,
878                });
879            }
880
881            msgs
882        });
883
884        *ctx.props().renderer.on_render_limits_changed.borrow_mut() = Some(cb);
885    }
886
887    // Presentation: on_is_workspace_changed
888    {
889        let cb = ctx.link().callback(UpdateIsWorkspace);
890        *ctx.props()
891            .presentation
892            .on_is_workspace_changed
893            .borrow_mut() = Some(cb);
894    }
895
896    // Drag/drop: on_dragstart (post-merge: lives on Presentation)
897    {
898        let presentation = ctx.props().presentation.clone();
899        let cb = ctx.link().callback(move |_: DragEffect| {
900            UpdateDragDrop(Box::new(presentation.drag_drop_props()))
901        });
902
903        *ctx.props().presentation.on_dragstart.borrow_mut() = Some(cb);
904    }
905
906    // Drag/drop: on_dragend
907    {
908        let cb = ctx.link().callback(|_: ()| UpdateDragDrop(Box::default()));
909        *ctx.props().presentation.on_dragend.borrow_mut() = Some(cb);
910    }
911}