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_client::config::ColumnType;
17use wasm_bindgen::prelude::*;
18use yew::prelude::*;
19
20use super::column_selector::ColumnSelector;
21use super::containers::split_panel::SplitPanel;
22use super::font_loader::{FontLoader, FontLoaderProps, FontLoaderStatus};
23use super::form::debug::DebugPanel;
24use super::plugin_selector::PluginSelector;
25use super::render_warning::RenderWarning;
26use super::status_bar::StatusBar;
27use super::style::{LocalStyle, StyleProvider};
28use crate::components::column_settings_sidebar::ColumnSettingsSidebar;
29use crate::components::containers::sidebar::SidebarCloseButton;
30use crate::config::*;
31use crate::custom_events::CustomEvents;
32use crate::dragdrop::*;
33use crate::model::*;
34use crate::presentation::Presentation;
35use crate::renderer::*;
36use crate::session::*;
37use crate::utils::*;
38use crate::*;
39
40/// Locates a view column.
41/// Table columns are those defined on the table, but their types will reflect
42/// the view type, not the table type.
43#[derive(Clone, Debug, PartialEq)]
44pub enum ColumnLocator {
45    Table(String),
46    Expression(String),
47    NewExpression,
48}
49impl ColumnLocator {
50    /// Pulls the column's name from the locator.
51    /// If the column is a new expression which has yet to be saved, the
52    /// function will return None.
53    pub fn name(&self) -> Option<&String> {
54        match self {
55            Self::Table(s) | Self::Expression(s) => Some(s),
56            Self::NewExpression => None,
57        }
58    }
59
60    pub fn name_or_default(&self, session: &Session) -> String {
61        match self {
62            Self::Table(s) | Self::Expression(s) => s.clone(),
63            Self::NewExpression => session.metadata().make_new_column_name(None),
64        }
65    }
66
67    pub fn is_active(&self, session: &Session) -> bool {
68        self.name()
69            .map(|name| session.is_column_active(name))
70            .unwrap_or_default()
71    }
72
73    #[inline(always)]
74    pub fn is_saved_expr(&self) -> bool {
75        matches!(self, ColumnLocator::Expression(_))
76    }
77
78    #[inline(always)]
79    pub fn is_expr(&self) -> bool {
80        matches!(
81            self,
82            ColumnLocator::Expression(_) | ColumnLocator::NewExpression
83        )
84    }
85
86    #[inline(always)]
87    pub fn is_new_expr(&self) -> bool {
88        matches!(self, ColumnLocator::NewExpression)
89    }
90
91    pub fn view_type(&self, session: &Session) -> Option<ColumnType> {
92        let name = self.name().cloned().unwrap_or_default();
93        session.metadata().get_column_view_type(name.as_str())
94    }
95}
96
97#[derive(Properties)]
98pub struct PerspectiveViewerProps {
99    pub elem: web_sys::HtmlElement,
100    pub session: Session,
101    pub renderer: Renderer,
102    pub presentation: Presentation,
103    pub dragdrop: DragDrop,
104    pub custom_events: CustomEvents,
105
106    #[prop_or_default]
107    pub weak_link: WeakScope<PerspectiveViewer>,
108}
109
110derive_model!(Renderer, Session, Presentation for PerspectiveViewerProps);
111
112impl PartialEq for PerspectiveViewerProps {
113    fn eq(&self, _rhs: &Self) -> bool {
114        false
115    }
116}
117
118impl PerspectiveViewerProps {
119    fn is_title(&self) -> bool {
120        !self.presentation.get_is_workspace() && self.presentation.get_title().is_some()
121    }
122}
123
124#[derive(Debug)]
125pub enum PerspectiveViewerMsg {
126    Resize,
127    Reset(bool, Option<Sender<()>>),
128    ToggleSettingsInit(Option<SettingsUpdate>, Option<Sender<ApiResult<JsValue>>>),
129    ToggleSettingsComplete(SettingsUpdate, Sender<()>),
130    ToggleDebug,
131    PreloadFontsUpdate,
132    RenderLimits(Option<(usize, usize, Option<usize>, Option<usize>)>),
133    SettingsPanelSizeUpdate(Option<i32>),
134    ColumnSettingsPanelSizeUpdate(Option<i32>),
135    Error,
136    OpenColumnSettings {
137        locator: Option<ColumnLocator>,
138        sender: Option<Sender<()>>,
139        toggle: bool,
140    },
141}
142
143pub struct PerspectiveViewer {
144    dimensions: Option<(usize, usize, Option<usize>, Option<usize>)>,
145    on_rendered: Option<Sender<()>>,
146    fonts: FontLoaderProps,
147    settings_open: bool,
148    debug_open: bool,
149    /// The column which will be opened in the ColumnSettingsSidebar
150    selected_column: Option<ColumnLocator>,
151    selected_column_is_active: bool, // TODO: should we use a struct?
152    on_resize: Rc<PubSub<()>>,
153    on_dimensions_reset: Rc<PubSub<()>>,
154    _subscriptions: [Subscription; 2],
155    settings_panel_width_override: Option<i32>,
156    column_settings_panel_width_override: Option<i32>,
157
158    on_close_column_settings: Callback<()>,
159}
160
161impl Component for PerspectiveViewer {
162    type Message = PerspectiveViewerMsg;
163    type Properties = PerspectiveViewerProps;
164
165    fn create(ctx: &Context<Self>) -> Self {
166        *ctx.props().weak_link.borrow_mut() = Some(ctx.link().clone());
167        let elem = ctx.props().elem.clone();
168        let callback = ctx
169            .link()
170            .callback(|()| PerspectiveViewerMsg::PreloadFontsUpdate);
171
172        let session_sub = {
173            clone!(
174                ctx.props().presentation,
175                ctx.props().session,
176                plugin_query = ctx.props().get_plugin_column_styles_query()
177            );
178            let callback = ctx.link().batch_callback(move |(update, render_limits)| {
179                if update {
180                    vec![PerspectiveViewerMsg::RenderLimits(Some(render_limits))]
181                } else {
182                    let locator =
183                        presentation
184                            .get_open_column_settings()
185                            .locator
186                            .filter(|locator| match &locator {
187                                ColumnLocator::Table(name) => {
188                                    locator.is_active(&session)
189                                        && plugin_query
190                                            .can_render_column_styles(name)
191                                            .unwrap_or_default()
192                                },
193                                _ => true,
194                            });
195
196                    vec![
197                        PerspectiveViewerMsg::RenderLimits(Some(render_limits)),
198                        PerspectiveViewerMsg::OpenColumnSettings {
199                            locator,
200                            sender: None,
201                            toggle: false,
202                        },
203                    ]
204                }
205            });
206            ctx.props()
207                .renderer
208                .render_limits_changed
209                .add_listener(callback)
210        };
211
212        let error_sub = ctx
213            .props()
214            .session
215            .table_errored
216            .add_listener(ctx.link().callback(|_| PerspectiveViewerMsg::Error));
217
218        let on_close_column_settings =
219            ctx.link()
220                .callback(|_| PerspectiveViewerMsg::OpenColumnSettings {
221                    locator: None,
222                    sender: None,
223                    toggle: false,
224                });
225
226        Self {
227            dimensions: None,
228            on_rendered: None,
229            fonts: FontLoaderProps::new(&elem, callback),
230            settings_open: false,
231            debug_open: false,
232            selected_column: None,
233            selected_column_is_active: false,
234            on_resize: Default::default(),
235            on_dimensions_reset: Default::default(),
236            _subscriptions: [session_sub, error_sub],
237            settings_panel_width_override: None,
238            column_settings_panel_width_override: None,
239            on_close_column_settings,
240        }
241    }
242
243    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
244        let needs_update = self.selected_column.is_some();
245        match msg {
246            PerspectiveViewerMsg::PreloadFontsUpdate => true,
247            PerspectiveViewerMsg::Resize => {
248                self.on_resize.emit(());
249                false
250            },
251            PerspectiveViewerMsg::Error => true,
252            PerspectiveViewerMsg::Reset(all, sender) => {
253                self.selected_column = None;
254                clone!(
255                    ctx.props().renderer,
256                    ctx.props().session,
257                    ctx.props().presentation
258                );
259
260                ApiFuture::spawn(async move {
261                    session.reset(all).await?;
262                    let columns_config = if all {
263                        presentation.reset_columns_configs();
264                        None
265                    } else {
266                        Some(presentation.all_columns_configs())
267                    };
268
269                    renderer.reset(columns_config.as_ref()).await?;
270                    presentation.reset_available_themes(None).await;
271                    if all {
272                        presentation.reset_theme().await?;
273                    }
274
275                    let result = renderer.draw(session.validate().await?.create_view()).await;
276                    if let Some(sender) = sender {
277                        sender.send(()).unwrap();
278                    }
279
280                    renderer.reset_changed.emit(());
281                    result
282                });
283
284                needs_update
285            },
286            PerspectiveViewerMsg::ToggleDebug => {
287                self.debug_open = !self.debug_open;
288                clone!(ctx.props().renderer, ctx.props().session);
289                ApiFuture::spawn(async move {
290                    renderer.draw(session.validate().await?.create_view()).await
291                });
292
293                true
294            },
295            PerspectiveViewerMsg::ToggleSettingsInit(Some(SettingsUpdate::Missing), None) => false,
296            PerspectiveViewerMsg::ToggleSettingsInit(
297                Some(SettingsUpdate::Missing),
298                Some(resolve),
299            ) => {
300                resolve.send(Ok(JsValue::UNDEFINED)).unwrap();
301                false
302            },
303            PerspectiveViewerMsg::ToggleSettingsInit(Some(SettingsUpdate::SetDefault), resolve) => {
304                self.init_toggle_settings_task(ctx, Some(false), resolve);
305                false
306            },
307            PerspectiveViewerMsg::ToggleSettingsInit(
308                Some(SettingsUpdate::Update(force)),
309                resolve,
310            ) => {
311                self.init_toggle_settings_task(ctx, Some(force), resolve);
312                false
313            },
314            PerspectiveViewerMsg::ToggleSettingsInit(None, resolve) => {
315                self.init_toggle_settings_task(ctx, None, resolve);
316                false
317            },
318            PerspectiveViewerMsg::ToggleSettingsComplete(SettingsUpdate::SetDefault, resolve)
319                if self.settings_open =>
320            {
321                self.selected_column = None;
322                self.settings_open = false;
323                self.on_rendered = Some(resolve);
324                true
325            },
326            PerspectiveViewerMsg::ToggleSettingsComplete(
327                SettingsUpdate::Update(force),
328                resolve,
329            ) if force != self.settings_open => {
330                self.selected_column = None;
331                self.settings_open = force;
332                self.on_rendered = Some(resolve);
333                true
334            },
335            PerspectiveViewerMsg::ToggleSettingsComplete(_, resolve)
336                if matches!(self.fonts.get_status(), FontLoaderStatus::Finished) =>
337            {
338                self.selected_column = None;
339                if let Err(e) = resolve.send(()) {
340                    tracing::error!("toggle settings failed {:?}", e);
341                }
342
343                false
344            },
345            PerspectiveViewerMsg::ToggleSettingsComplete(_, resolve) => {
346                self.selected_column = None;
347                self.on_rendered = Some(resolve);
348                true
349            },
350            PerspectiveViewerMsg::RenderLimits(dimensions) => {
351                if self.dimensions != dimensions {
352                    self.dimensions = dimensions;
353                    true
354                } else {
355                    false
356                }
357            },
358            PerspectiveViewerMsg::OpenColumnSettings {
359                locator,
360                sender,
361                toggle,
362            } => {
363                let is_active = locator
364                    .as_ref()
365                    .map(|l| l.is_active(&ctx.props().session))
366                    .unwrap_or_default();
367
368                self.selected_column_is_active = is_active;
369                if toggle && self.selected_column == locator {
370                    self.selected_column = None;
371                    (false, None)
372                } else {
373                    self.selected_column.clone_from(&locator);
374
375                    locator
376                        .clone()
377                        .map(|c| (true, c.name().cloned()))
378                        .unwrap_or_default()
379                };
380
381                let mut open_column_settings = ctx.props().presentation.get_open_column_settings();
382                open_column_settings
383                    .locator
384                    .clone_from(&self.selected_column);
385
386                ctx.props()
387                    .presentation
388                    .set_open_column_settings(Some(open_column_settings));
389
390                if let Some(sender) = sender {
391                    sender.send(()).unwrap();
392                }
393
394                true
395            },
396            PerspectiveViewerMsg::SettingsPanelSizeUpdate(Some(x)) => {
397                self.settings_panel_width_override = Some(x);
398                false
399            },
400            PerspectiveViewerMsg::SettingsPanelSizeUpdate(None) => {
401                self.settings_panel_width_override = None;
402                false
403            },
404            PerspectiveViewerMsg::ColumnSettingsPanelSizeUpdate(Some(x)) => {
405                self.column_settings_panel_width_override = Some(x);
406                false
407            },
408            PerspectiveViewerMsg::ColumnSettingsPanelSizeUpdate(None) => {
409                self.column_settings_panel_width_override = None;
410                false
411            },
412        }
413    }
414
415    /// This top-level component is mounted to the Custom Element, so it has no
416    /// API to provide props - but for sanity if needed, just return true on
417    /// change.
418    fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
419        true
420    }
421
422    /// On rendered call notify_resize().  This also triggers any registered
423    /// async callbacks to the Custom Element API.
424    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
425        ctx.props()
426            .presentation
427            .set_settings_open(Some(self.settings_open))
428            .unwrap();
429
430        if self.on_rendered.is_some()
431            && matches!(self.fonts.get_status(), FontLoaderStatus::Finished)
432            && self.on_rendered.take().unwrap().send(()).is_err()
433        {
434            tracing::warn!("Orphan render");
435        }
436    }
437
438    /// `PerspectiveViewer` has two basic UI modes - "open" and "closed".
439    fn view(&self, ctx: &Context<Self>) -> Html {
440        let settings = ctx
441            .link()
442            .callback(|_| PerspectiveViewerMsg::ToggleSettingsInit(None, None));
443
444        let on_close_settings = ctx
445            .link()
446            .callback(|()| PerspectiveViewerMsg::ToggleSettingsInit(None, None));
447
448        let on_toggle_debug = ctx.link().callback(|_| PerspectiveViewerMsg::ToggleDebug);
449        let mut class = classes!("settings-closed");
450        if ctx.props().is_title() {
451            class.push("titled");
452        }
453
454        let on_open_expr_panel =
455            ctx.link()
456                .callback(|c| PerspectiveViewerMsg::OpenColumnSettings {
457                    locator: Some(c),
458                    sender: None,
459                    toggle: true,
460                });
461
462        let on_reset = ctx
463            .link()
464            .callback(|all| PerspectiveViewerMsg::Reset(all, None));
465
466        let on_split_panel_resize = ctx
467            .link()
468            .callback(|(x, _)| PerspectiveViewerMsg::SettingsPanelSizeUpdate(Some(x)));
469
470        let on_column_settings_panel_resize = ctx
471            .link()
472            .callback(|(x, _)| PerspectiveViewerMsg::ColumnSettingsPanelSizeUpdate(Some(x)));
473
474        let settings_panel = html! {
475            <div id="settings_panel" class="sidebar_column noselect split-panel orient-vertical">
476                if self.selected_column.is_none() {
477                    <SidebarCloseButton
478                        id="settings_close_button"
479                        on_close_sidebar={&on_close_settings}
480                    />
481                }
482                <SidebarCloseButton
483                    id={if self.debug_open { "debug_close_button" } else { "debug_open_button" }}
484                    on_close_sidebar={&on_toggle_debug}
485                />
486                <PluginSelector
487                    session={&ctx.props().session}
488                    renderer={&ctx.props().renderer}
489                    presentation={&ctx.props().presentation}
490                />
491                <ColumnSelector
492                    dragdrop={&ctx.props().dragdrop}
493                    renderer={&ctx.props().renderer}
494                    session={&ctx.props().session}
495                    presentation={&ctx.props().presentation}
496                    on_resize={&self.on_resize}
497                    on_open_expr_panel={&on_open_expr_panel}
498                    on_dimensions_reset={&self.on_dimensions_reset}
499                    selected_column={self.selected_column.clone()}
500                />
501            </div>
502        };
503
504        let main_panel = html! {
505            <div id="main_column">
506                <StatusBar
507                    id="status_bar"
508                    session={&ctx.props().session}
509                    renderer={&ctx.props().renderer}
510                    presentation={&ctx.props().presentation}
511                    on_reset={on_reset.clone()}
512                />
513                <div id="main_panel_container">
514                    <RenderWarning
515                        dimensions={self.dimensions}
516                        session={&ctx.props().session}
517                        renderer={&ctx.props().renderer}
518                    />
519                    <slot />
520                </div>
521                if let Some(selected_column) = self.selected_column.clone() {
522                    <SplitPanel
523                        id="modal_panel"
524                        reverse=true
525                        initial_size={self.column_settings_panel_width_override}
526                        on_reset={ctx.link().callback(|_| PerspectiveViewerMsg::ColumnSettingsPanelSizeUpdate(None))}
527                        on_resize={on_column_settings_panel_resize}
528                    >
529                        <ColumnSettingsSidebar
530                            session={&ctx.props().session}
531                            renderer={&ctx.props().renderer}
532                            custom_events={&ctx.props().custom_events}
533                            presentation={&ctx.props().presentation}
534                            {selected_column}
535                            on_close={self.on_close_column_settings.clone()}
536                            width_override={self.column_settings_panel_width_override}
537                            is_active={self.selected_column_is_active}
538                        />
539                        <></>
540                    </SplitPanel>
541                }
542            </div>
543        };
544
545        html! {
546            <>
547                <StyleProvider root={ctx.props().elem.clone()}>
548                    <LocalStyle href={css!("viewer")} />
549                    if self.settings_open && ctx.props().session.has_table() {
550                        if self.debug_open {
551                            <SplitPanel
552                                id="app_panel"
553                                reverse=true
554                                initial_size={self.settings_panel_width_override}
555                                on_reset={ctx.link().callback(|_| PerspectiveViewerMsg::SettingsPanelSizeUpdate(None))}
556                                on_resize={on_split_panel_resize}
557                                on_resize_finished={ctx.props().render_callback()}
558                            >
559                                <DebugPanel
560                                    session={ctx.props().session()}
561                                    renderer={ctx.props().renderer()}
562                                    presentation={ctx.props().presentation()}
563                                />
564                                { settings_panel }
565                                { main_panel }
566                            </SplitPanel>
567                        } else {
568                            <SplitPanel
569                                id="app_panel"
570                                reverse=true
571                                initial_size={self.settings_panel_width_override}
572                                on_reset={ctx.link().callback(|_| PerspectiveViewerMsg::SettingsPanelSizeUpdate(None))}
573                                on_resize={on_split_panel_resize}
574                                on_resize_finished={ctx.props().resize_callback()}
575                            >
576                                { settings_panel }
577                                { main_panel }
578                            </SplitPanel>
579                        }
580                    } else {
581                        <RenderWarning
582                            dimensions={self.dimensions}
583                            session={&ctx.props().session}
584                            renderer={&ctx.props().renderer}
585                        />
586                        if ctx.props().is_title() || !ctx.props().session.has_table() || ctx.props().session.is_errored() {
587                            <StatusBar
588                                id="status_bar"
589                                session={&ctx.props().session}
590                                renderer={&ctx.props().renderer}
591                                presentation={&ctx.props().presentation}
592                                {on_reset}
593                            />
594                        }
595                        <div id="main_panel_container" {class}><slot /></div>
596                        if !ctx.props().presentation.get_is_workspace() {
597                            <div
598                                id="settings_button"
599                                class={if ctx.props().is_title() { "noselect button closed titled" } else { "noselect button closed" }}
600                                onmousedown={settings}
601                            />
602                        }
603                    }
604                </StyleProvider>
605                <FontLoader ..self.fonts.clone() />
606            </>
607        }
608    }
609
610    fn destroy(&mut self, _ctx: &Context<Self>) {}
611}
612
613impl PerspectiveViewer {
614    /// Toggle the settings, or force the settings panel either open (true) or
615    /// closed (false) explicitly.  In order to reduce apparent
616    /// screen-shear, `toggle_settings()` uses a somewhat complex render
617    /// order:  it first resize the plugin's `<div>` without moving it,
618    /// using `overflow: hidden` to hide the extra draw area;  then,
619    /// after the _async_ drawing of the plugin is complete, it will send a
620    /// message to complete the toggle action and re-render the element with
621    /// the settings removed.
622    ///
623    /// # Arguments
624    /// * `force` - Whether to explicitly set the settings panel state to
625    ///   Open/Close (`Some(true)`/`Some(false)`), or to just toggle the current
626    ///   state (`None`).
627    fn init_toggle_settings_task(
628        &mut self,
629        ctx: &Context<Self>,
630        force: Option<bool>,
631        sender: Option<Sender<ApiResult<JsValue>>>,
632    ) {
633        let is_open = ctx.props().presentation.is_settings_open();
634        match force {
635            Some(force) if is_open == force => {
636                if let Some(sender) = sender {
637                    sender.send(Ok(JsValue::UNDEFINED)).unwrap();
638                }
639            },
640            Some(_) | None => {
641                let force = !is_open;
642                let callback = ctx.link().callback(move |resolve| {
643                    let update = SettingsUpdate::Update(force);
644                    PerspectiveViewerMsg::ToggleSettingsComplete(update, resolve)
645                });
646
647                clone!(ctx.props().renderer, ctx.props().session);
648                ApiFuture::spawn(async move {
649                    let result = if session.js_get_table().is_some() {
650                        renderer.presize(force, callback.emit_async_safe()).await
651                    } else {
652                        callback.emit_async_safe().await?;
653                        Ok(JsValue::UNDEFINED)
654                    };
655
656                    if let Some(sender) = sender {
657                        let msg = result.ignore_view_delete();
658                        sender
659                            .send(msg.map(|x| x.unwrap_or(JsValue::UNDEFINED)))
660                            .into_apierror()?;
661                    };
662
663                    Ok(JsValue::undefined())
664                });
665            },
666        };
667    }
668}