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