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 perspective_client::config::Filter;
14use wasm_bindgen_futures::spawn_local;
15use web_sys::*;
16use yew::prelude::*;
17
18use super::status_indicator::StatusIndicator;
19use crate::components::containers::select::*;
20use crate::components::copy_dropdown::CopyDropDownMenu;
21use crate::components::export_dropdown::ExportDropDownMenu;
22use crate::components::global_filter_bar::GlobalFilterBar;
23use crate::components::portal::PortalModal;
24use crate::components::status_bar_counter::StatusBarRowsCounter;
25use crate::components::style::StyleSurface;
26use crate::config::*;
27use crate::js::*;
28use crate::presentation::{Presentation, PresentationProps};
29use crate::renderer::*;
30use crate::session::*;
31use crate::tasks::*;
32use crate::utils::*;
33use crate::workspace::Workspace;
34use crate::*;
35
36#[derive(Clone, Properties)]
37pub struct StatusBarProps {
38    // DOM Attribute
39    pub id: String,
40
41    /// Fired when the reset button is clicked.
42    pub on_reset: Callback<bool>,
43
44    /// Snapshots threaded from root.  Component reads `has_table`, `stats`,
45    /// `error`, `title` from session_props; `selected_theme`,
46    /// `available_themes`, `is_workspace` from presentation_props.
47    pub session_props: SessionProps,
48    pub presentation_props: PresentationProps,
49
50    /// Derived from root: `settings_open && has_table_loaded`.  Used
51    /// here to drive the title-input enabled state and the theme picker
52    /// visibility.
53    pub is_settings_open: bool,
54
55    /// In-flight render counter, threaded to `StatusIndicator`.
56    pub update_count: u32,
57
58    /// Element-level global filters (fed by master/detail selection); rendered
59    /// as removable chips between the row stats and the menu icons.
60    pub global_filters: Vec<Filter>,
61
62    /// Remove the global filter at this index (a chip's ×).
63    pub on_remove_global_filter: Callback<usize>,
64
65    /// Clear all global filters (the "Clear" affordance).
66    pub on_clear_global_filters: Callback<()>,
67
68    // State
69    pub session: Session,
70    pub renderer: Renderer,
71    pub presentation: Presentation,
72
73    /// The multi-panel model, so a theme change can restyle EVERY panel (not
74    /// just the active one this status bar targets) — non-active panels that
75    /// inherit the host theme otherwise render stale CSS until they redraw.
76    pub workspace: Workspace,
77}
78
79impl PartialEq for StatusBarProps {
80    fn eq(&self, other: &Self) -> bool {
81        self.id == other.id
82            && self.session_props == other.session_props
83            && self.presentation_props == other.presentation_props
84            && self.is_settings_open == other.is_settings_open
85            && self.update_count == other.update_count
86            && self.global_filters == other.global_filters
87    }
88}
89
90pub enum StatusBarMsg {
91    Reset(MouseEvent),
92    Export,
93    Copy,
94    CloseExport,
95    CloseCopy,
96    Eject,
97    SetTheme(String),
98    ResetTheme,
99    PointerEvent(web_sys::PointerEvent),
100}
101
102/// A toolbar with buttons, and `Table` & `View` status information.
103pub struct StatusBar {
104    copy_ref: NodeRef,
105    export_ref: NodeRef,
106    statusbar_ref: NodeRef,
107    copy_target: Option<HtmlElement>,
108    export_target: Option<HtmlElement>,
109}
110
111impl Component for StatusBar {
112    type Message = StatusBarMsg;
113    type Properties = StatusBarProps;
114
115    fn create(_ctx: &Context<Self>) -> Self {
116        Self {
117            copy_ref: NodeRef::default(),
118            export_ref: NodeRef::default(),
119            statusbar_ref: NodeRef::default(),
120            copy_target: None,
121            export_target: None,
122        }
123    }
124
125    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
126        match msg {
127            StatusBarMsg::Reset(event) => {
128                let all = event.shift_key();
129                ctx.props().on_reset.emit(all);
130                false
131            },
132            StatusBarMsg::ResetTheme => {
133                update_theme(
134                    &ctx.props().renderer,
135                    &ctx.props().presentation,
136                    &ctx.props().workspace,
137                    None,
138                );
139                true
140            },
141            StatusBarMsg::SetTheme(theme_name) => {
142                update_theme(
143                    &ctx.props().renderer,
144                    &ctx.props().presentation,
145                    &ctx.props().workspace,
146                    Some(theme_name),
147                );
148                false
149            },
150            StatusBarMsg::Export => {
151                self.export_target = self.export_ref.cast::<HtmlElement>();
152                true
153            },
154            StatusBarMsg::Copy => {
155                self.copy_target = self.copy_ref.cast::<HtmlElement>();
156                true
157            },
158            StatusBarMsg::CloseExport => {
159                self.export_target = None;
160                true
161            },
162            StatusBarMsg::CloseCopy => {
163                self.copy_target = None;
164                true
165            },
166            StatusBarMsg::Eject => {
167                ctx.props().presentation.on_eject.emit(());
168                false
169            },
170            StatusBarMsg::PointerEvent(event) => {
171                if event.target().map(JsValue::from)
172                    == self.statusbar_ref.cast::<HtmlElement>().map(JsValue::from)
173                {
174                    ctx.props().presentation.statusbar_pointer_event.emit(event);
175                }
176
177                false
178            },
179        }
180    }
181
182    fn view(&self, ctx: &Context<Self>) -> Html {
183        let Self::Properties {
184            renderer, session, ..
185        } = ctx.props();
186
187        let has_table = ctx.props().session_props.has_table.clone();
188        let is_errored = ctx.props().session_props.is_errored();
189        let is_settings_open = ctx.props().is_settings_open;
190        let title = &ctx.props().session_props.title;
191
192        let mut is_updating_class_name = classes!();
193        if !is_settings_open {
194            is_updating_class_name.push("settings-closed");
195        };
196
197        if !matches!(has_table, Some(TableLoadState::Loaded)) {
198            is_updating_class_name.push("updating");
199        }
200
201        // TODO Memoizing these would reduce some vdom diffing later on
202        let onclose = ctx.link().callback(|_| StatusBarMsg::Eject);
203        let onpointerdown = ctx.link().callback(StatusBarMsg::PointerEvent);
204        let onexport = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Export);
205        let oncopy = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Copy);
206        let onreset = ctx.link().callback(StatusBarMsg::Reset);
207
208        // Project only the *active* panel's plugin toolbar into the shared status
209        // bar. Each panel's toolbar slots into `statusbar-extra-{its-panel-id}`
210        // (see datagrid `toolbar.ts`); the active panel's id comes from the
211        // active renderer this status bar is bound to.
212        let extra_slot = ctx
213            .props()
214            .renderer
215            .slot_name()
216            .map(|id| format!("statusbar-extra-{id}"))
217            .unwrap_or_else(|| "statusbar-extra".to_owned());
218        let is_menu = matches!(has_table, Some(TableLoadState::Loaded)) && is_settings_open;
219        // The editable title moved to the per-panel `<PanelTab>` headers; the
220        // status bar no longer hosts an `<input>`, so its `is_active` factor is
221        // gone. `is_title` now only gates the row counter.
222        let is_title =
223            is_menu || ctx.props().presentation_props.is_workspace || title.is_some() || is_errored;
224
225        let is_settings =
226            !matches!(has_table, Some(TableLoadState::Loaded)) || is_errored || is_settings_open;
227
228        let on_copy_select = {
229            let props = ctx.props().clone();
230            let link = ctx.link().clone();
231            Callback::from(move |x: ExportFile| {
232                let props = props.clone();
233                let link = link.clone();
234                spawn_local(async move {
235                    let mime = x.method.mimetype(x.is_chart);
236                    let task = export_method_to_blob(
237                        &props.session,
238                        &props.renderer,
239                        &props.presentation,
240                        x.method,
241                    );
242                    let result = copy_to_clipboard(task, mime).await;
243                    let r = (|| -> ApiResult<()> {
244                        result?;
245                        link.send_message(StatusBarMsg::CloseCopy);
246                        Ok(())
247                    })();
248                    if let Err(e) = r {
249                        web_sys::console::warn_1(&e.into());
250                    }
251                })
252            })
253        };
254
255        let on_export_select = {
256            let props = ctx.props().clone();
257            let link = ctx.link().clone();
258            Callback::from(move |x: ExportFile| {
259                if !x.name.is_empty() {
260                    clone!(props, link);
261                    spawn_local(async move {
262                        let val = export_method_to_blob(
263                            &props.session,
264                            &props.renderer,
265                            &props.presentation,
266                            x.method,
267                        )
268                        .await
269                        .unwrap();
270                        let is_chart = props.renderer.is_chart();
271                        download(&x.as_filename(is_chart), &val).unwrap();
272                        link.send_message(StatusBarMsg::CloseExport);
273                    })
274                }
275            })
276        };
277
278        let on_close_copy = ctx.link().callback(|_| StatusBarMsg::CloseCopy);
279        let on_close_export = ctx.link().callback(|_| StatusBarMsg::CloseExport);
280
281        if is_settings {
282            html! {
283                <>
284                    <div
285                        ref={&self.statusbar_ref}
286                        id={ctx.props().id.clone()}
287                        class={is_updating_class_name}
288                        {onpointerdown}
289                    >
290                        <StatusIndicator
291                            {renderer}
292                            {session}
293                            update_count={ctx.props().update_count}
294                            session_props={ctx.props().session_props.clone()}
295                        />
296                        if is_title {
297                            <StatusBarRowsCounter stats={ctx.props().session_props.stats.clone()} />
298                        }
299                        // Global-filter chips sit between the row stats and the
300                        // menu icons (rendered only when there are filters).
301                        if !ctx.props().global_filters.is_empty() {
302                            <GlobalFilterBar
303                                filters={ctx.props().global_filters.clone()}
304                                on_remove={ctx.props().on_remove_global_filter.clone()}
305                                on_clear={ctx.props().on_clear_global_filters.clone()}
306                            />
307                        }
308                        <div id="spacer" />
309                        if is_menu {
310                            <div id="menu-bar" class="section">
311                                <ThemeSelector
312                                    theme={ctx.props().presentation_props.selected_theme.clone()}
313                                    themes={ctx.props().presentation_props.available_themes.clone()}
314                                    on_change={ctx.link().callback(StatusBarMsg::SetTheme)}
315                                    on_reset={ctx.link().callback(|_| StatusBarMsg::ResetTheme)}
316                                />
317                                <div id="plugin-settings"><slot name={extra_slot} /></div>
318                                <span class="hover-target">
319                                    <span id="reset" class="button" onmousedown={&onreset}>
320                                        <span class="icon shift-alt-icon" />
321                                        <span class="icon-label" />
322                                    </span>
323                                </span>
324                                <span
325                                    ref={&self.export_ref}
326                                    class="hover-target"
327                                    onmousedown={onexport}
328                                >
329                                    <span id="export" class="button">
330                                        <span class="icon" />
331                                        <span class="icon-label" />
332                                    </span>
333                                </span>
334                                <span
335                                    ref={&self.copy_ref}
336                                    class="hover-target"
337                                    onmousedown={oncopy}
338                                >
339                                    <span id="copy" class="button">
340                                        <span class="icon" />
341                                        <span class="icon-label" />
342                                    </span>
343                                </span>
344                            </div>
345                        }
346                        if !is_settings_open {
347                            <div id="close_button" class="noselect" onmousedown={onclose}>
348                                <span class="icon" />
349                            </div>
350                        }
351                    </div>
352                    <PortalModal
353                        tag_name="perspective-copy-menu"
354                        surface={StyleSurface::DropdownMenu}
355                        target={self.copy_target.clone()}
356                        own_focus=true
357                        on_close={on_close_copy}
358                        theme={ctx.props().presentation_props.selected_theme.clone().unwrap_or_default()}
359                    >
360                        <CopyDropDownMenu renderer={renderer.clone()} callback={on_copy_select} />
361                    </PortalModal>
362                    <PortalModal
363                        tag_name="perspective-export-menu"
364                        surface={StyleSurface::DropdownMenu}
365                        target={self.export_target.clone()}
366                        own_focus=true
367                        on_close={on_close_export}
368                        theme={ctx.props().presentation_props.selected_theme.clone().unwrap_or_default()}
369                    >
370                        <ExportDropDownMenu
371                            renderer={renderer.clone()}
372                            session={session.clone()}
373                            callback={on_export_select}
374                        />
375                    </PortalModal>
376                </>
377            }
378        } else {
379            // Settings closed + loaded: no docked status bar. The open-settings
380            // affordance lives on the `PanelTab`s; only the (default-hidden)
381            // eject button floats here.
382            let class = classes!(is_updating_class_name, "floating");
383            html! {
384                <div id={ctx.props().id.clone()} {class}>
385                    <div id="close_button" class="noselect" onmousedown={&onclose} />
386                </div>
387            }
388        }
389    }
390}
391
392#[derive(Properties, PartialEq)]
393struct ThemeSelectorProps {
394    pub theme: Option<String>,
395    pub themes: PtrEqRc<Vec<String>>,
396    pub on_reset: Callback<()>,
397    pub on_change: Callback<String>,
398}
399
400#[function_component]
401fn ThemeSelector(props: &ThemeSelectorProps) -> Html {
402    let is_first = props
403        .theme
404        .as_ref()
405        .and_then(|x| props.themes.first().map(|y| y == x))
406        .unwrap_or_default();
407
408    let values = use_memo(props.themes.clone(), |themes| {
409        themes
410            .iter()
411            .cloned()
412            .map(SelectItem::Option)
413            .collect::<Vec<_>>()
414    });
415
416    match &props.theme {
417        None => html! {},
418        Some(selected) => {
419            html! {
420                if values.len() > 1 {
421                    <span class="hover-target">
422                        <div
423                            id="theme_icon"
424                            class={if is_first {""} else {"modified"}}
425                            tabindex="0"
426                            onclick={props.on_reset.reform(|_| ())}
427                        />
428                        <span id="theme" class="button">
429                            <span class="icon" />
430                            <Select<String>
431                                id="theme_selector"
432                                class="invert"
433                                {values}
434                                selected={selected.to_owned()}
435                                on_select={props.on_change.clone()}
436                            />
437                        </span>
438                    </span>
439                }
440            }
441        },
442    }
443}