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 std::rc::Rc;
14
15use web_sys::*;
16use yew::prelude::*;
17
18use super::status_indicator::StatusIndicator;
19use super::style::LocalStyle;
20use crate::components::containers::select::*;
21use crate::components::status_bar_counter::StatusBarRowsCounter;
22use crate::custom_elements::copy_dropdown::*;
23use crate::custom_elements::export_dropdown::*;
24use crate::custom_events::CustomEvents;
25use crate::model::*;
26use crate::presentation::Presentation;
27use crate::renderer::*;
28use crate::session::*;
29use crate::utils::*;
30use crate::*;
31
32#[derive(Properties, PerspectiveProperties!)]
33pub struct StatusBarProps {
34    // DOM Attribute
35    pub id: String,
36
37    /// Fired when the reset button is clicked.
38    pub on_reset: Callback<bool>,
39
40    /// Fires when the settings button is clicked
41    #[prop_or_default]
42    pub on_settings: Option<Callback<()>>,
43
44    // State
45    pub custom_events: CustomEvents,
46    pub session: Session,
47    pub renderer: Renderer,
48    pub presentation: Presentation,
49}
50
51impl PartialEq for StatusBarProps {
52    fn eq(&self, other: &Self) -> bool {
53        self.id == other.id
54    }
55}
56
57pub enum StatusBarMsg {
58    Reset(MouseEvent),
59    Export,
60    Copy,
61    Noop,
62    Eject,
63    SetThemeConfig((Rc<Vec<String>>, Option<usize>)),
64    SetTheme(String),
65    ResetTheme,
66    PointerEvent(web_sys::PointerEvent),
67    TitleInputEvent,
68    TitleChangeEvent,
69}
70
71/// A toolbar with buttons, and `Table` & `View` status information.
72pub struct StatusBar {
73    _subscriptions: [Subscription; 5],
74    copy_ref: NodeRef,
75    export_ref: NodeRef,
76    input_ref: NodeRef,
77    statusbar_ref: NodeRef,
78    theme: Option<String>,
79    themes: Rc<Vec<String>>,
80    title: Option<String>,
81}
82
83impl Component for StatusBar {
84    type Message = StatusBarMsg;
85    type Properties = StatusBarProps;
86
87    fn create(ctx: &Context<Self>) -> Self {
88        fetch_initial_theme(ctx);
89        Self {
90            _subscriptions: register_listeners(ctx),
91            copy_ref: NodeRef::default(),
92            export_ref: NodeRef::default(),
93            input_ref: NodeRef::default(),
94            statusbar_ref: NodeRef::default(),
95            theme: None,
96            themes: vec![].into(),
97            title: ctx.props().session().get_title().clone(),
98        }
99    }
100
101    fn changed(&mut self, ctx: &Context<Self>, _old_props: &Self::Properties) -> bool {
102        self._subscriptions = register_listeners(ctx);
103        true
104    }
105
106    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
107        maybe_log_or_default!(Ok(match msg {
108            StatusBarMsg::Reset(event) => {
109                let all = event.shift_key();
110                ctx.props().on_reset.emit(all);
111                false
112            },
113            StatusBarMsg::ResetTheme => {
114                let state = ctx.props().clone_state();
115                ApiFuture::spawn(async move {
116                    state.presentation.reset_theme().await?;
117                    let view = state.session.get_view().into_apierror()?;
118                    state.renderer.restyle_all(&view).await
119                });
120                true
121            },
122            StatusBarMsg::SetThemeConfig((themes, index)) => {
123                let new_theme = index.and_then(|x| themes.get(x)).cloned();
124                let should_render = new_theme != self.theme || self.themes != themes;
125                self.theme = new_theme;
126                self.themes = themes;
127                should_render
128            },
129            StatusBarMsg::SetTheme(theme_name) => {
130                let state = ctx.props().clone_state();
131                ApiFuture::spawn(async move {
132                    state.presentation.set_theme_name(Some(&theme_name)).await?;
133                    let view = state.session.get_view().into_apierror()?;
134                    state.renderer.restyle_all(&view).await
135                });
136
137                false
138            },
139            StatusBarMsg::Export => {
140                let target = self.export_ref.cast::<HtmlElement>().into_apierror()?;
141                ExportDropDownMenuElement::new_from_model(ctx.props()).open(target);
142                false
143            },
144            StatusBarMsg::Copy => {
145                let target = self.copy_ref.cast::<HtmlElement>().into_apierror()?;
146                CopyDropDownMenuElement::new_from_model(ctx.props()).open(target);
147                false
148            },
149            StatusBarMsg::Eject => {
150                ctx.props().presentation().on_eject.emit(());
151                false
152            },
153            StatusBarMsg::Noop => {
154                self.title = ctx.props().session().get_title();
155                true
156            },
157            StatusBarMsg::TitleInputEvent => {
158                let elem = self.input_ref.cast::<HtmlInputElement>().into_apierror()?;
159                let title = elem.value();
160                let title = if title.trim().is_empty() {
161                    None
162                } else {
163                    Some(title)
164                };
165
166                self.title = title;
167                true
168            },
169            StatusBarMsg::TitleChangeEvent => {
170                let elem = self.input_ref.cast::<HtmlInputElement>().into_apierror()?;
171                let title = elem.value();
172                let title = if title.trim().is_empty() {
173                    None
174                } else {
175                    Some(title)
176                };
177
178                ctx.props().session().set_title(title);
179                false
180            },
181            StatusBarMsg::PointerEvent(event) => {
182                if event.target().map(JsValue::from)
183                    == self.statusbar_ref.cast::<HtmlElement>().map(JsValue::from)
184                {
185                    ctx.props()
186                        .custom_events()
187                        .dispatch_event(format!("statusbar-{}", event.type_()).as_str(), &event)?;
188                }
189
190                false
191            },
192        }))
193    }
194
195    fn view(&self, ctx: &Context<Self>) -> Html {
196        let Self::Properties {
197            custom_events,
198            presentation,
199            renderer,
200            session,
201            ..
202        } = ctx.props();
203
204        let mut is_updating_class_name = classes!();
205        if session.get_title().is_some() {
206            is_updating_class_name.push("titled");
207        };
208
209        if !presentation.is_settings_open() {
210            is_updating_class_name.push(["settings-closed", "titled"]);
211        };
212
213        if !session.has_table() {
214            is_updating_class_name.push("updating");
215        }
216
217        // TODO Memoizing these would reduce some vdom diffing later on
218        let onblur = ctx.link().callback(|_| StatusBarMsg::Noop);
219        let onclose = ctx.link().callback(|_| StatusBarMsg::Eject);
220        let onpointerdown = ctx.link().callback(StatusBarMsg::PointerEvent);
221        let onexport = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Export);
222        let oncopy = ctx.link().callback(|_: MouseEvent| StatusBarMsg::Copy);
223        let onreset = ctx.link().callback(StatusBarMsg::Reset);
224        let onchange = ctx
225            .link()
226            .callback(|_: Event| StatusBarMsg::TitleChangeEvent);
227
228        let oninput = ctx
229            .link()
230            .callback(|_: InputEvent| StatusBarMsg::TitleInputEvent);
231
232        let is_menu = session.has_table() && ctx.props().on_settings.as_ref().is_none();
233        let is_title = is_menu
234            || presentation.get_is_workspace()
235            || session.get_title().is_some()
236            || session.is_errored()
237            || presentation.is_active(&self.input_ref.cast::<Element>());
238
239        let is_settings = session.get_title().is_some()
240            || presentation.get_is_workspace()
241            || !session.has_table()
242            || session.is_errored()
243            || presentation.is_settings_open()
244            || presentation.is_active(&self.input_ref.cast::<Element>());
245
246        if is_settings {
247            html! {
248                <>
249                    <LocalStyle href={css!("status-bar")} />
250                    <div
251                        ref={&self.statusbar_ref}
252                        id={ctx.props().id.clone()}
253                        class={is_updating_class_name}
254                        {onpointerdown}
255                    >
256                        <StatusIndicator {custom_events} {renderer} {session} />
257                        if is_title {
258                            <label
259                                class="input-sizer"
260                                data-value={self.title.clone().unwrap_or_default()}
261                            >
262                                <input
263                                    ref={&self.input_ref}
264                                    placeholder=""
265                                    value={self.title.clone().unwrap_or_default()}
266                                    size="10"
267                                    {onblur}
268                                    {onchange}
269                                    {oninput}
270                                />
271                                <span id="status-bar-placeholder" />
272                            </label>
273                        }
274                        if is_title {
275                            <StatusBarRowsCounter {session} />
276                        }
277                        <div id="spacer" />
278                        if is_menu {
279                            <div id="menu-bar" class="section">
280                                <ThemeSelector
281                                    theme={self.theme.clone()}
282                                    themes={self.themes.clone()}
283                                    on_change={ctx.link().callback(StatusBarMsg::SetTheme)}
284                                    on_reset={ctx.link().callback(|_| StatusBarMsg::ResetTheme)}
285                                />
286                                <div id="plugin-settings"><slot name="statusbar-extra" /></div>
287                                <span class="hover-target">
288                                    <span id="reset" class="button" onmousedown={&onreset}>
289                                        <span />
290                                    </span>
291                                </span>
292                                <span
293                                    ref={&self.export_ref}
294                                    class="hover-target"
295                                    onmousedown={onexport}
296                                >
297                                    <span id="export" class="button"><span /></span>
298                                </span>
299                                <span
300                                    ref={&self.copy_ref}
301                                    class="hover-target"
302                                    onmousedown={oncopy}
303                                >
304                                    <span id="copy" class="button"><span /></span>
305                                </span>
306                            </div>
307                        }
308                        if let Some(x) = ctx.props().on_settings.as_ref() {
309                            <div
310                                id="settings_button"
311                                class="noselect"
312                                onmousedown={x.reform(|_| ())}
313                            />
314                            <div id="close_button" class="noselect" onmousedown={onclose} />
315                        }
316                    </div>
317                </>
318            }
319        } else if let Some(x) = ctx.props().on_settings.as_ref() {
320            let class = classes!(is_updating_class_name, "floating");
321            html! {
322                <div id={ctx.props().id.clone()} {class}>
323                    <div id="settings_button" class="noselect" onmousedown={x.reform(|_| ())} />
324                    <div id="close_button" class="noselect" onmousedown={&onclose} />
325                </div>
326            }
327        } else {
328            html! {}
329        }
330    }
331}
332
333fn register_listeners(ctx: &Context<StatusBar>) -> [Subscription; 5] {
334    [
335        ctx.props()
336            .presentation()
337            .theme_config_updated
338            .add_listener(ctx.link().callback(StatusBarMsg::SetThemeConfig)),
339        ctx.props()
340            .presentation()
341            .visibility_changed
342            .add_listener(ctx.link().callback(|_| StatusBarMsg::Noop)),
343        ctx.props()
344            .session()
345            .title_changed
346            .add_listener(ctx.link().callback(|_| StatusBarMsg::Noop)),
347        ctx.props()
348            .session()
349            .table_loaded
350            .add_listener(ctx.link().callback(|_| StatusBarMsg::Noop)),
351        ctx.props()
352            .session()
353            .table_errored
354            .add_listener(ctx.link().callback(|_| StatusBarMsg::Noop)),
355    ]
356}
357
358fn fetch_initial_theme(ctx: &Context<StatusBar>) {
359    ApiFuture::spawn({
360        let on_theme = ctx.link().callback(StatusBarMsg::SetThemeConfig);
361        clone!(ctx.props().presentation());
362        async move {
363            on_theme.emit(presentation.get_selected_theme_config().await?);
364            Ok(())
365        }
366    });
367}
368
369#[derive(Properties, PartialEq)]
370struct ThemeSelectorProps {
371    pub theme: Option<String>,
372    pub themes: Rc<Vec<String>>,
373    pub on_reset: Callback<()>,
374    pub on_change: Callback<String>,
375}
376
377#[function_component]
378fn ThemeSelector(props: &ThemeSelectorProps) -> Html {
379    let is_first = props
380        .theme
381        .as_ref()
382        .and_then(|x| props.themes.first().map(|y| y == x))
383        .unwrap_or_default();
384
385    let values = use_memo(props.themes.clone(), |themes| {
386        themes
387            .iter()
388            .cloned()
389            .map(SelectItem::Option)
390            .collect::<Vec<_>>()
391    });
392
393    match &props.theme {
394        None => html! {},
395        Some(selected) => {
396            html! {
397                if values.len() > 1 {
398                    <span class="hover-target">
399                        <div
400                            id="theme_icon"
401                            class={if is_first {""} else {"modified"}}
402                            tabindex="0"
403                            onclick={props.on_reset.reform(|_| ())}
404                        />
405                        <span id="theme" class="button">
406                            <Select<String>
407                                id="theme_selector"
408                                class="invert"
409                                {values}
410                                selected={selected.to_owned()}
411                                on_select={props.on_change.clone()}
412                            />
413                        </span>
414                    </span>
415                }
416            }
417        },
418    }
419}