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::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    OpenColumnSettings {
136        locator: Option<ColumnLocator>,
137        sender: Option<Sender<()>>,
138        toggle: bool,
139    },
140}
141
142pub struct PerspectiveViewer {
143    dimensions: Option<(usize, usize, Option<usize>, Option<usize>)>,
144    on_rendered: Option<Sender<()>>,
145    fonts: FontLoaderProps,
146    settings_open: bool,
147    debug_open: bool,
148    /// The column which will be opened in the ColumnSettingsSidebar
149    selected_column: Option<ColumnLocator>,
150    selected_column_is_active: bool, // TODO: should we use a struct?
151    on_resize: Rc<PubSub<()>>,
152    on_dimensions_reset: Rc<PubSub<()>>,
153    _subscriptions: [Subscription; 1],
154    settings_panel_width_override: Option<i32>,
155    column_settings_panel_width_override: Option<i32>,
156
157    on_close_column_settings: Callback<()>,
158}
159
160impl Component for PerspectiveViewer {
161    type Message = PerspectiveViewerMsg;
162    type Properties = PerspectiveViewerProps;
163
164    fn create(ctx: &Context<Self>) -> Self {
165        *ctx.props().weak_link.borrow_mut() = Some(ctx.link().clone());
166        let elem = ctx.props().elem.clone();
167        let callback = ctx
168            .link()
169            .callback(|()| PerspectiveViewerMsg::PreloadFontsUpdate);
170
171        let session_sub = {
172            clone!(
173                ctx.props().presentation,
174                ctx.props().session,
175                plugin_query = ctx.props().get_plugin_column_styles_query()
176            );
177            let callback = ctx.link().batch_callback(move |(update, render_limits)| {
178                if update {
179                    vec![PerspectiveViewerMsg::RenderLimits(Some(render_limits))]
180                } else {
181                    let locator =
182                        presentation
183                            .get_open_column_settings()
184                            .locator
185                            .filter(|locator| match &locator {
186                                ColumnLocator::Table(name) => {
187                                    locator.is_active(&session)
188                                        && plugin_query
189                                            .can_render_column_styles(name)
190                                            .unwrap_or_default()
191                                },
192                                _ => true,
193                            });
194
195                    vec![
196                        PerspectiveViewerMsg::RenderLimits(Some(render_limits)),
197                        PerspectiveViewerMsg::OpenColumnSettings {
198                            locator,
199                            sender: None,
200                            toggle: false,
201                        },
202                    ]
203                }
204            });
205            ctx.props()
206                .renderer
207                .render_limits_changed
208                .add_listener(callback)
209        };
210
211        let on_close_column_settings =
212            ctx.link()
213                .callback(|_| PerspectiveViewerMsg::OpenColumnSettings {
214                    locator: None,
215                    sender: None,
216                    toggle: false,
217                });
218
219        Self {
220            dimensions: None,
221            on_rendered: None,
222            fonts: FontLoaderProps::new(&elem, callback),
223            settings_open: false,
224            debug_open: false,
225            selected_column: None,
226            selected_column_is_active: false,
227            on_resize: Default::default(),
228            on_dimensions_reset: Default::default(),
229            _subscriptions: [session_sub],
230            settings_panel_width_override: None,
231            column_settings_panel_width_override: None,
232            on_close_column_settings,
233        }
234    }
235
236    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
237        let needs_update = self.selected_column.is_some();
238        match msg {
239            PerspectiveViewerMsg::PreloadFontsUpdate => true,
240            PerspectiveViewerMsg::Resize => {
241                self.on_resize.emit(());
242                false
243            },
244            PerspectiveViewerMsg::Reset(all, sender) => {
245                self.selected_column = None;
246                clone!(
247                    ctx.props().renderer,
248                    ctx.props().session,
249                    ctx.props().presentation
250                );
251
252                ApiFuture::spawn(async move {
253                    session.reset(all).await?;
254                    let columns_config = if all {
255                        presentation.reset_columns_configs();
256                        None
257                    } else {
258                        Some(presentation.all_columns_configs())
259                    };
260
261                    renderer.reset(columns_config.as_ref()).await?;
262                    presentation.reset_available_themes(None).await;
263                    let result = renderer.draw(session.validate().await?.create_view()).await;
264                    if let Some(sender) = sender {
265                        sender.send(()).unwrap();
266                    }
267
268                    renderer.reset_changed.emit(());
269                    result
270                });
271
272                needs_update
273            },
274            PerspectiveViewerMsg::ToggleDebug => {
275                self.debug_open = !self.debug_open;
276                clone!(ctx.props().renderer, ctx.props().session);
277                ApiFuture::spawn(async move {
278                    renderer.draw(session.validate().await?.create_view()).await
279                });
280
281                true
282            },
283            PerspectiveViewerMsg::ToggleSettingsInit(Some(SettingsUpdate::Missing), None) => false,
284            PerspectiveViewerMsg::ToggleSettingsInit(
285                Some(SettingsUpdate::Missing),
286                Some(resolve),
287            ) => {
288                resolve.send(Ok(JsValue::UNDEFINED)).unwrap();
289                false
290            },
291            PerspectiveViewerMsg::ToggleSettingsInit(Some(SettingsUpdate::SetDefault), resolve) => {
292                self.init_toggle_settings_task(ctx, Some(false), resolve);
293                false
294            },
295            PerspectiveViewerMsg::ToggleSettingsInit(
296                Some(SettingsUpdate::Update(force)),
297                resolve,
298            ) => {
299                self.init_toggle_settings_task(ctx, Some(force), resolve);
300                false
301            },
302            PerspectiveViewerMsg::ToggleSettingsInit(None, resolve) => {
303                self.init_toggle_settings_task(ctx, None, resolve);
304                false
305            },
306            PerspectiveViewerMsg::ToggleSettingsComplete(SettingsUpdate::SetDefault, resolve)
307                if self.settings_open =>
308            {
309                self.selected_column = None;
310                self.settings_open = false;
311                self.on_rendered = Some(resolve);
312                true
313            },
314            PerspectiveViewerMsg::ToggleSettingsComplete(
315                SettingsUpdate::Update(force),
316                resolve,
317            ) if force != self.settings_open => {
318                self.selected_column = None;
319                self.settings_open = force;
320                self.on_rendered = Some(resolve);
321                true
322            },
323            PerspectiveViewerMsg::ToggleSettingsComplete(_, resolve)
324                if matches!(self.fonts.get_status(), FontLoaderStatus::Finished) =>
325            {
326                self.selected_column = None;
327                resolve.send(()).expect("Orphan render");
328                false
329            },
330            PerspectiveViewerMsg::ToggleSettingsComplete(_, resolve) => {
331                self.selected_column = None;
332                self.on_rendered = Some(resolve);
333                true
334            },
335            PerspectiveViewerMsg::RenderLimits(dimensions) => {
336                if self.dimensions != dimensions {
337                    self.dimensions = dimensions;
338                    true
339                } else {
340                    false
341                }
342            },
343            PerspectiveViewerMsg::OpenColumnSettings {
344                locator,
345                sender,
346                toggle,
347            } => {
348                let is_active = locator
349                    .as_ref()
350                    .map(|l| l.is_active(&ctx.props().session))
351                    .unwrap_or_default();
352
353                self.selected_column_is_active = is_active;
354                if toggle && self.selected_column == locator {
355                    self.selected_column = None;
356                    (false, None)
357                } else {
358                    self.selected_column.clone_from(&locator);
359
360                    locator
361                        .clone()
362                        .map(|c| (true, c.name().cloned()))
363                        .unwrap_or_default()
364                };
365
366                let mut open_column_settings = ctx.props().presentation.get_open_column_settings();
367                open_column_settings
368                    .locator
369                    .clone_from(&self.selected_column);
370
371                ctx.props()
372                    .presentation
373                    .set_open_column_settings(Some(open_column_settings));
374
375                if let Some(sender) = sender {
376                    sender.send(()).unwrap();
377                }
378
379                true
380            },
381            PerspectiveViewerMsg::SettingsPanelSizeUpdate(Some(x)) => {
382                self.settings_panel_width_override = Some(x);
383                false
384            },
385            PerspectiveViewerMsg::SettingsPanelSizeUpdate(None) => {
386                self.settings_panel_width_override = None;
387                false
388            },
389            PerspectiveViewerMsg::ColumnSettingsPanelSizeUpdate(Some(x)) => {
390                self.column_settings_panel_width_override = Some(x);
391                false
392            },
393            PerspectiveViewerMsg::ColumnSettingsPanelSizeUpdate(None) => {
394                self.column_settings_panel_width_override = None;
395                false
396            },
397        }
398    }
399
400    /// This top-level component is mounted to the Custom Element, so it has no
401    /// API to provide props - but for sanity if needed, just return true on
402    /// change.
403    fn changed(&mut self, _ctx: &Context<Self>, _old: &Self::Properties) -> bool {
404        true
405    }
406
407    /// On rendered call notify_resize().  This also triggers any registered
408    /// async callbacks to the Custom Element API.
409    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
410        ctx.props()
411            .presentation
412            .set_settings_open(Some(self.settings_open))
413            .unwrap();
414
415        if self.on_rendered.is_some()
416            && matches!(self.fonts.get_status(), FontLoaderStatus::Finished)
417        {
418            self.on_rendered
419                .take()
420                .unwrap()
421                .send(())
422                .expect("Orphan render");
423        }
424    }
425
426    /// `PerspectiveViewer` has two basic UI modes - "open" and "closed".
427    fn view(&self, ctx: &Context<Self>) -> Html {
428        let settings = ctx
429            .link()
430            .callback(|_| PerspectiveViewerMsg::ToggleSettingsInit(None, None));
431
432        let on_close_settings = ctx
433            .link()
434            .callback(|()| PerspectiveViewerMsg::ToggleSettingsInit(None, None));
435
436        let on_toggle_debug = ctx.link().callback(|_| PerspectiveViewerMsg::ToggleDebug);
437        let mut class = classes!("settings-closed");
438        if ctx.props().is_title() {
439            class.push("titled");
440        }
441
442        let on_open_expr_panel =
443            ctx.link()
444                .callback(|c| PerspectiveViewerMsg::OpenColumnSettings {
445                    locator: Some(c),
446                    sender: None,
447                    toggle: true,
448                });
449
450        let on_reset = ctx
451            .link()
452            .callback(|all| PerspectiveViewerMsg::Reset(all, None));
453
454        let on_split_panel_resize = ctx
455            .link()
456            .callback(|(x, _)| PerspectiveViewerMsg::SettingsPanelSizeUpdate(Some(x)));
457
458        let on_column_settings_panel_resize = ctx
459            .link()
460            .callback(|(x, _)| PerspectiveViewerMsg::ColumnSettingsPanelSizeUpdate(Some(x)));
461
462        let settings_panel = html! {
463            <div id="settings_panel" class="sidebar_column noselect split-panel orient-vertical">
464                if self.selected_column.is_none() {
465                    <SidebarCloseButton
466                        id="settings_close_button"
467                        on_close_sidebar={&on_close_settings}
468                    />
469                }
470                <SidebarCloseButton
471                    id={if self.debug_open { "debug_close_button" } else { "debug_open_button" }}
472                    on_close_sidebar={&on_toggle_debug}
473                />
474                <PluginSelector
475                    session={&ctx.props().session}
476                    renderer={&ctx.props().renderer}
477                    presentation={&ctx.props().presentation}
478                />
479                <ColumnSelector
480                    dragdrop={&ctx.props().dragdrop}
481                    renderer={&ctx.props().renderer}
482                    session={&ctx.props().session}
483                    presentation={&ctx.props().presentation}
484                    on_resize={&self.on_resize}
485                    on_open_expr_panel={&on_open_expr_panel}
486                    on_dimensions_reset={&self.on_dimensions_reset}
487                    selected_column={self.selected_column.clone()}
488                />
489            </div>
490        };
491
492        let main_panel = html! {
493            <div id="main_column">
494                <StatusBar
495                    id="status_bar"
496                    session={&ctx.props().session}
497                    renderer={&ctx.props().renderer}
498                    presentation={&ctx.props().presentation}
499                    on_reset={on_reset.clone()}
500                />
501                <div id="main_panel_container">
502                    <RenderWarning
503                        dimensions={self.dimensions}
504                        session={&ctx.props().session}
505                        renderer={&ctx.props().renderer}
506                    />
507                    <slot />
508                </div>
509                if let Some(selected_column) = self.selected_column.clone() {
510                    <SplitPanel
511                        id="modal_panel"
512                        reverse=true
513                        initial_size={self.column_settings_panel_width_override}
514                        on_reset={ctx.link().callback(|_| PerspectiveViewerMsg::ColumnSettingsPanelSizeUpdate(None))}
515                        on_resize={on_column_settings_panel_resize}
516                    >
517                        <ColumnSettingsSidebar
518                            session={&ctx.props().session}
519                            renderer={&ctx.props().renderer}
520                            custom_events={&ctx.props().custom_events}
521                            presentation={&ctx.props().presentation}
522                            {selected_column}
523                            on_close={self.on_close_column_settings.clone()}
524                            width_override={self.column_settings_panel_width_override}
525                            is_active={self.selected_column_is_active}
526                        />
527                        <></>
528                    </SplitPanel>
529                }
530            </div>
531        };
532
533        html! {
534            <>
535                <StyleProvider>
536                    <LocalStyle href={css!("viewer")} />
537                    if self.settings_open && ctx.props().session.has_table() {
538                        if self.debug_open {
539                            <SplitPanel
540                                id="app_panel"
541                                reverse=true
542                                initial_size={self.settings_panel_width_override}
543                                on_reset={ctx.link().callback(|_| PerspectiveViewerMsg::SettingsPanelSizeUpdate(None))}
544                                on_resize={on_split_panel_resize}
545                                on_resize_finished={ctx.props().render_callback()}
546                            >
547                                <DebugPanel
548                                    session={ctx.props().session()}
549                                    renderer={ctx.props().renderer()}
550                                    presentation={ctx.props().presentation()}
551                                />
552                                { settings_panel }
553                                { main_panel }
554                            </SplitPanel>
555                        } else {
556                            <SplitPanel
557                                id="app_panel"
558                                reverse=true
559                                initial_size={self.settings_panel_width_override}
560                                on_reset={ctx.link().callback(|_| PerspectiveViewerMsg::SettingsPanelSizeUpdate(None))}
561                                on_resize={on_split_panel_resize}
562                                on_resize_finished={ctx.props().resize_callback()}
563                            >
564                                { settings_panel }
565                                { main_panel }
566                            </SplitPanel>
567                        }
568                    } else {
569                        <RenderWarning
570                            dimensions={self.dimensions}
571                            session={&ctx.props().session}
572                            renderer={&ctx.props().renderer}
573                        />
574                        if ctx.props().is_title() || !ctx.props().session.has_table() {
575                            <StatusBar
576                                id="status_bar"
577                                session={&ctx.props().session}
578                                renderer={&ctx.props().renderer}
579                                presentation={&ctx.props().presentation}
580                                {on_reset}
581                            />
582                        }
583                        <div id="main_panel_container" {class}><slot /></div>
584                        if !ctx.props().presentation.get_is_workspace() {
585                            <div
586                                id="settings_button"
587                                class={if ctx.props().is_title() { "noselect button closed titled" } else { "noselect button closed" }}
588                                onmousedown={settings}
589                            />
590                        }
591                    }
592                </StyleProvider>
593                <FontLoader ..self.fonts.clone() />
594            </>
595        }
596    }
597
598    fn destroy(&mut self, _ctx: &Context<Self>) {}
599}
600
601impl PerspectiveViewer {
602    /// Toggle the settings, or force the settings panel either open (true) or
603    /// closed (false) explicitly.  In order to reduce apparent
604    /// screen-shear, `toggle_settings()` uses a somewhat complex render
605    /// order:  it first resize the plugin's `<div>` without moving it,
606    /// using `overflow: hidden` to hide the extra draw area;  then,
607    /// after the _async_ drawing of the plugin is complete, it will send a
608    /// message to complete the toggle action and re-render the element with
609    /// the settings removed.
610    ///
611    /// # Arguments
612    /// * `force` - Whether to explicitly set the settings panel state to
613    ///   Open/Close (`Some(true)`/`Some(false)`), or to just toggle the current
614    ///   state (`None`).
615    fn init_toggle_settings_task(
616        &mut self,
617        ctx: &Context<Self>,
618        force: Option<bool>,
619        sender: Option<Sender<ApiResult<JsValue>>>,
620    ) {
621        let is_open = ctx.props().presentation.is_settings_open();
622        match force {
623            Some(force) if is_open == force => {
624                if let Some(sender) = sender {
625                    sender.send(Ok(JsValue::UNDEFINED)).unwrap();
626                }
627            },
628            Some(_) | None => {
629                let force = !is_open;
630                let callback = ctx.link().callback(move |resolve| {
631                    let update = SettingsUpdate::Update(force);
632                    PerspectiveViewerMsg::ToggleSettingsComplete(update, resolve)
633                });
634
635                clone!(ctx.props().renderer, ctx.props().session);
636                ApiFuture::spawn(async move {
637                    let result = if session.js_get_table().is_some() {
638                        renderer.presize(force, callback.emit_async_safe()).await
639                    } else {
640                        callback.emit_async_safe().await?;
641                        Ok(JsValue::UNDEFINED)
642                    };
643
644                    if let Some(sender) = sender {
645                        let msg = result.clone().ignore_view_delete();
646                        sender.send(msg).into_apierror()?;
647                    };
648
649                    result
650                });
651            },
652        };
653    }
654}