Skip to main content

perspective_viewer/components/
status_bar.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 wasm_bindgen_futures::spawn_local;
14use web_sys::*;
15use yew::prelude::*;
16
17use super::status_indicator::StatusIndicator;
18use super::style::LocalStyle;
19use crate::components::containers::select::*;
20use crate::components::copy_dropdown::CopyDropDownMenu;
21use crate::components::export_dropdown::ExportDropDownMenu;
22use crate::components::portal::PortalModal;
23use crate::components::status_bar_counter::StatusBarRowsCounter;
24use crate::custom_events::CustomEvents;
25use crate::js::*;
26use crate::presentation::Presentation;
27use crate::renderer::*;
28use crate::session::*;
29use crate::tasks::*;
30use crate::utils::*;
31use crate::*;
32
33#[derive(Clone, Properties)]
34pub struct StatusBarProps {
35    // DOM Attribute
36    pub id: String,
37
38    /// Fired when the reset button is clicked.
39    pub on_reset: Callback<bool>,
40
41    /// Fires when the settings button is clicked
42    #[prop_or_default]
43    pub on_settings: Option<Callback<()>>,
44
45    // Value props threaded from the root's `SessionProps`.
46    // Using these avoids PubSub subscriptions for table_loaded / table_errored.
47    pub has_table: Option<TableLoadState>,
48    pub is_errored: bool,
49    pub stats: Option<ViewStats>,
50    /// In-flight render counter and full error, threaded to `StatusIndicator`.
51    pub update_count: u32,
52    pub error: Option<TableErrorState>,
53    /// Title string from session — threaded to avoid title_changed
54    /// subscription.
55    pub title: Option<String>,
56    /// Theme state from presentation — threaded to avoid theme_config_updated /
57    /// visibility_changed subscriptions.
58    pub is_settings_open: bool,
59    pub selected_theme: Option<String>,
60    pub available_themes: PtrEqRc<Vec<String>>,
61    /// Whether this viewer is hosted inside a `<perspective-workspace>`.
62    pub is_workspace: bool,
63
64    // State
65    pub custom_events: CustomEvents,
66    pub session: Session,
67    pub renderer: Renderer,
68    pub presentation: Presentation,
69}
70
71impl PartialEq for StatusBarProps {
72    fn eq(&self, other: &Self) -> bool {
73        self.id == other.id
74            && self.has_table == other.has_table
75            && self.is_errored == other.is_errored
76            && self.stats == other.stats
77            && self.update_count == other.update_count
78            && self.error == other.error
79            && self.title == other.title
80            && self.is_settings_open == other.is_settings_open
81            && self.selected_theme == other.selected_theme
82            && self.available_themes == other.available_themes
83            && self.is_workspace == other.is_workspace
84    }
85}
86
87impl HasCustomEvents for StatusBarProps {
88    fn custom_events(&self) -> &CustomEvents {
89        &self.custom_events
90    }
91}
92
93impl HasPresentation for StatusBarProps {
94    fn presentation(&self) -> &Presentation {
95        &self.presentation
96    }
97}
98
99impl HasRenderer for StatusBarProps {
100    fn renderer(&self) -> &Renderer {
101        &self.renderer
102    }
103}
104
105impl HasSession for StatusBarProps {
106    fn session(&self) -> &Session {
107        &self.session
108    }
109}
110
111impl StateProvider for StatusBarProps {
112    type State = StatusBarProps;
113
114    fn clone_state(&self) -> Self::State {
115        self.clone()
116    }
117}
118
119pub enum StatusBarMsg {
120    Reset(MouseEvent),
121    Export,
122    Copy,
123    CloseExport,
124    CloseCopy,
125    Noop,
126    Eject,
127    SetTheme(String),
128    ResetTheme,
129    PointerEvent(web_sys::PointerEvent),
130    TitleInputEvent,
131    TitleChangeEvent,
132}
133
134/// A toolbar with buttons, and `Table` & `View` status information.
135pub struct StatusBar {
136    copy_ref: NodeRef,
137    export_ref: NodeRef,
138    input_ref: NodeRef,
139    statusbar_ref: NodeRef,
140    /// Local title tracks the live `<input>` value before the user commits the
141    /// change (blur / Enter).  Reset to the prop value whenever the prop
142    /// changes.
143    title: Option<String>,
144    copy_target: Option<HtmlElement>,
145    export_target: Option<HtmlElement>,
146}
147
148impl Component for StatusBar {
149    type Message = StatusBarMsg;
150    type Properties = StatusBarProps;
151
152    fn create(ctx: &Context<Self>) -> Self {
153        Self {
154            copy_ref: NodeRef::default(),
155            export_ref: NodeRef::default(),
156            input_ref: NodeRef::default(),
157            statusbar_ref: NodeRef::default(),
158            title: ctx.props().title.clone(),
159            copy_target: None,
160            export_target: None,
161        }
162    }
163
164    fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
165        // Keep the local title in sync with the prop whenever the session title
166        // changes externally (e.g. restore() call) or the settings panel opens /
167        // closes (which resets the input element).
168        if ctx.props().title != old_props.title
169            || ctx.props().is_settings_open != old_props.is_settings_open
170        {
171            self.title = ctx.props().title.clone();
172        }
173        true
174    }
175
176    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
177        maybe_log_or_default!(Ok(match msg {
178            StatusBarMsg::Reset(event) => {
179                let all = event.shift_key();
180                ctx.props().on_reset.emit(all);
181                false
182            },
183            StatusBarMsg::ResetTheme => {
184                let presentation = ctx.props().presentation.clone();
185                let session = ctx.props().session.clone();
186                let renderer = ctx.props().renderer.clone();
187                ApiFuture::spawn(async move {
188                    presentation.reset_theme().await?;
189                    let view = session.get_view().into_apierror()?;
190                    renderer.restyle_all(&view).await
191                });
192                true
193            },
194            StatusBarMsg::SetTheme(theme_name) => {
195                let presentation = ctx.props().presentation.clone();
196                let session = ctx.props().session.clone();
197                let renderer = ctx.props().renderer.clone();
198                ApiFuture::spawn(async move {
199                    presentation.set_theme_name(Some(&theme_name)).await?;
200                    let view = session.get_view().into_apierror()?;
201                    renderer.restyle_all(&view).await
202                });
203
204                false
205            },
206            StatusBarMsg::Export => {
207                self.export_target = self.export_ref.cast::<HtmlElement>();
208                true
209            },
210            StatusBarMsg::Copy => {
211                self.copy_target = self.copy_ref.cast::<HtmlElement>();
212                true
213            },
214            StatusBarMsg::CloseExport => {
215                self.export_target = None;
216                true
217            },
218            StatusBarMsg::CloseCopy => {
219                self.copy_target = None;
220                true
221            },
222            StatusBarMsg::Eject => {
223                ctx.props().presentation().on_eject.emit(());
224                false
225            },
226            StatusBarMsg::Noop => {
227                self.title = ctx.props().title.clone();
228                true
229            },
230            StatusBarMsg::TitleInputEvent => {
231                let elem = self.input_ref.cast::<HtmlInputElement>().into_apierror()?;
232                let title = elem.value();
233                let title = if title.trim().is_empty() {
234                    None
235                } else {
236                    Some(title)
237                };
238
239                self.title = title;
240                true
241            },
242            StatusBarMsg::TitleChangeEvent => {
243                let elem = self.input_ref.cast::<HtmlInputElement>().into_apierror()?;
244                let title = elem.value();
245                let title = if title.trim().is_empty() {
246                    None
247                } else {
248                    Some(title)
249                };
250
251                ctx.props().session().set_title(title);
252                false
253            },
254            StatusBarMsg::PointerEvent(event) => {
255                if event.target().map(JsValue::from)
256                    == self.statusbar_ref.cast::<HtmlElement>().map(JsValue::from)
257                {
258                    ctx.props()
259                        .custom_events()
260                        .dispatch_event(format!("statusbar-{}", event.type_()).as_str(), &event)?;
261                }
262
263                false
264            },
265        }))
266    }
267
268    fn view(&self, ctx: &Context<Self>) -> Html {
269        let Self::Properties {
270            custom_events,
271            presentation,
272            renderer,
273            session,
274            ..
275        } = ctx.props();
276
277        let has_table = ctx.props().has_table.clone();
278        let is_errored = ctx.props().is_errored;
279        let is_settings_open = ctx.props().is_settings_open;
280        let title = &ctx.props().title;
281
282        let mut is_updating_class_name = classes!();
283        if title.is_some() {
284            is_updating_class_name.push("titled");
285        };
286
287        if !is_settings_open {
288            is_updating_class_name.push(["settings-closed", "titled"]);
289        };
290
291        if !matches!(has_table, Some(TableLoadState::Loaded)) {
292            is_updating_class_name.push("updating");
293        }
294
295        // TODO Memoizing these would reduce some vdom diffing later on
296        let onblur = ctx.link().callback(|_| StatusBarMsg::Noop);
297        let onclose = ctx.link().callback(|_| StatusBarMsg::Eject);
298        let onpointerdown = ctx.link().callback(StatusBarMsg::PointerEvent);
299        let onexport = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Export);
300        let oncopy = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Copy);
301        let onreset = ctx.link().callback(StatusBarMsg::Reset);
302        let onchange = ctx
303            .link()
304            .callback(|_: Event| StatusBarMsg::TitleChangeEvent);
305
306        let oninput = ctx
307            .link()
308            .callback(|_: InputEvent| StatusBarMsg::TitleInputEvent);
309
310        let is_menu = matches!(has_table, Some(TableLoadState::Loaded))
311            && ctx.props().on_settings.as_ref().is_none();
312        let is_title = is_menu
313            || ctx.props().is_workspace
314            || title.is_some()
315            || is_errored
316            || presentation.is_active(&self.input_ref.cast::<Element>());
317
318        let is_settings = title.is_some()
319            || ctx.props().is_workspace
320            || !matches!(has_table, Some(TableLoadState::Loaded))
321            || is_errored
322            || is_settings_open
323            || presentation.is_active(&self.input_ref.cast::<Element>());
324
325        let on_copy_select = {
326            let props = ctx.props().clone();
327            let link = ctx.link().clone();
328            Callback::from(move |x: ExportFile| {
329                let props = props.clone();
330                let link = link.clone();
331                spawn_local(async move {
332                    let mime = x.method.mimetype(x.is_chart);
333                    let task = props.export_method_to_blob(x.method);
334                    let result = copy_to_clipboard(task, mime).await;
335                    crate::maybe_log!({
336                        result?;
337                        link.send_message(StatusBarMsg::CloseCopy);
338                    })
339                })
340            })
341        };
342
343        let on_export_select = {
344            let props = ctx.props().clone();
345            let link = ctx.link().clone();
346            Callback::from(move |x: ExportFile| {
347                if !x.name.is_empty() {
348                    clone!(props, link);
349                    spawn_local(async move {
350                        let val = props.export_method_to_blob(x.method).await.unwrap();
351                        let is_chart = props.renderer().is_chart();
352                        download(&x.as_filename(is_chart), &val).unwrap();
353                        link.send_message(StatusBarMsg::CloseExport);
354                    })
355                }
356            })
357        };
358
359        let on_close_copy = ctx.link().callback(|_| StatusBarMsg::CloseCopy);
360        let on_close_export = ctx.link().callback(|_| StatusBarMsg::CloseExport);
361
362        if is_settings {
363            html! {
364                <>
365                    <LocalStyle href={css!("status-bar")} />
366                    <div
367                        ref={&self.statusbar_ref}
368                        id={ctx.props().id.clone()}
369                        class={is_updating_class_name}
370                        {onpointerdown}
371                    >
372                        <StatusIndicator
373                            {custom_events}
374                            {renderer}
375                            {session}
376                            update_count={ctx.props().update_count}
377                            error={ctx.props().error.clone()}
378                            has_table={ctx.props().has_table.clone()}
379                            stats={ctx.props().stats.clone()}
380                        />
381                        if is_title {
382                            <label
383                                class="input-sizer"
384                                data-value={self.title.clone().unwrap_or_default()}
385                            >
386                                <input
387                                    ref={&self.input_ref}
388                                    placeholder=""
389                                    value={self.title.clone().unwrap_or_default()}
390                                    size="10"
391                                    {onblur}
392                                    {onchange}
393                                    {oninput}
394                                />
395                                <span id="status-bar-placeholder" />
396                            </label>
397                        }
398                        if is_title {
399                            <StatusBarRowsCounter stats={ctx.props().stats.clone()} />
400                        }
401                        <div id="spacer" />
402                        if is_menu {
403                            <div id="menu-bar" class="section">
404                                <ThemeSelector
405                                    theme={ctx.props().selected_theme.clone()}
406                                    themes={ctx.props().available_themes.clone()}
407                                    on_change={ctx.link().callback(StatusBarMsg::SetTheme)}
408                                    on_reset={ctx.link().callback(|_| StatusBarMsg::ResetTheme)}
409                                />
410                                <div id="plugin-settings"><slot name="statusbar-extra" /></div>
411                                <span class="hover-target">
412                                    <span id="reset" class="button" onmousedown={&onreset}>
413                                        <span class="icon" />
414                                        <span class="icon-label" />
415                                    </span>
416                                </span>
417                                <span
418                                    ref={&self.export_ref}
419                                    class="hover-target"
420                                    onmousedown={onexport}
421                                >
422                                    <span id="export" class="button">
423                                        <span class="icon" />
424                                        <span class="icon-label" />
425                                    </span>
426                                </span>
427                                <span
428                                    ref={&self.copy_ref}
429                                    class="hover-target"
430                                    onmousedown={oncopy}
431                                >
432                                    <span id="copy" class="button">
433                                        <span class="icon" />
434                                        <span class="icon-label" />
435                                    </span>
436                                </span>
437                            </div>
438                        }
439                        if let Some(x) = ctx.props().on_settings.as_ref() {
440                            <div
441                                id="settings_button"
442                                class="noselect"
443                                onmousedown={x.reform(|_| ())}
444                            >
445                                <span class="icon" />
446                            </div>
447                            <div id="close_button" class="noselect" onmousedown={onclose}>
448                                <span class="icon" />
449                            </div>
450                        }
451                    </div>
452                    <PortalModal
453                        tag_name="perspective-copy-menu"
454                        target={self.copy_target.clone()}
455                        own_focus=true
456                        on_close={on_close_copy}
457                        theme={ctx.props().selected_theme.clone().unwrap_or_default()}
458                    >
459                        <CopyDropDownMenu renderer={renderer.clone()} callback={on_copy_select} />
460                    </PortalModal>
461                    <PortalModal
462                        tag_name="perspective-export-menu"
463                        target={self.export_target.clone()}
464                        own_focus=true
465                        on_close={on_close_export}
466                        theme={ctx.props().selected_theme.clone().unwrap_or_default()}
467                    >
468                        <ExportDropDownMenu
469                            renderer={renderer.clone()}
470                            session={session.clone()}
471                            callback={on_export_select}
472                        />
473                    </PortalModal>
474                </>
475            }
476        } else if let Some(x) = ctx.props().on_settings.as_ref() {
477            let class = classes!(is_updating_class_name, "floating");
478            html! {
479                <div id={ctx.props().id.clone()} {class}>
480                    <div id="settings_button" class="noselect" onmousedown={x.reform(|_| ())}>
481                        <span class="icon" />
482                    </div>
483                    <div id="close_button" class="noselect" onmousedown={&onclose} />
484                </div>
485            }
486        } else {
487            html! {}
488        }
489    }
490}
491
492#[derive(Properties, PartialEq)]
493struct ThemeSelectorProps {
494    pub theme: Option<String>,
495    pub themes: PtrEqRc<Vec<String>>,
496    pub on_reset: Callback<()>,
497    pub on_change: Callback<String>,
498}
499
500#[function_component]
501fn ThemeSelector(props: &ThemeSelectorProps) -> Html {
502    let is_first = props
503        .theme
504        .as_ref()
505        .and_then(|x| props.themes.first().map(|y| y == x))
506        .unwrap_or_default();
507
508    let values = use_memo(props.themes.clone(), |themes| {
509        themes
510            .iter()
511            .cloned()
512            .map(SelectItem::Option)
513            .collect::<Vec<_>>()
514    });
515
516    match &props.theme {
517        None => html! {},
518        Some(selected) => {
519            html! {
520                if values.len() > 1 {
521                    <span class="hover-target">
522                        <div
523                            id="theme_icon"
524                            class={if is_first {""} else {"modified"}}
525                            tabindex="0"
526                            onclick={props.on_reset.reform(|_| ())}
527                        />
528                        <span id="theme" class="button">
529                            <span class="icon" />
530                            <Select<String>
531                                id="theme_selector"
532                                class="invert"
533                                {values}
534                                selected={selected.to_owned()}
535                                on_select={props.on_change.clone()}
536                            />
537                        </span>
538                    </span>
539                }
540            }
541        },
542    }
543}