Skip to main content

perspective_viewer/components/
panel_tab.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::cell::Cell;
14use std::rc::Rc;
15
16use perspective_js::utils::global;
17use wasm_bindgen::JsCast;
18use wasm_bindgen::prelude::*;
19use web_sys::*;
20use yew::prelude::*;
21
22use crate::components::rows_counter::RowsCounter;
23use crate::session::{Session, ViewStats};
24use crate::utils::Subscription;
25
26#[wasm_bindgen(inline_js = r#"
27    export function define_panel_tab(name) {
28        if (!customElements.get(name)) {
29            customElements.define(name, class extends HTMLElement {});
30        }
31    }
32"#)]
33extern "C" {
34    #[wasm_bindgen(js_name = "define_panel_tab")]
35    fn define_panel_tab(name: &str);
36}
37
38/// The tag the tab host is created as (a custom element, so it can own a
39/// ShadowRoot for its contents).
40const TAB_TAG: &str = "perspective-viewer-tab";
41
42thread_local! {
43    /// Whether the `<perspective-viewer-tab>` custom element has been defined
44    /// (once per page; WASM is single-threaded).
45    static ELEMENT_DEFINED: Cell<bool> = const { Cell::new(false) };
46
47    /// The shared, constructed stylesheet adopted into every tab's ShadowRoot.
48    static TAB_SHEET: CssStyleSheet = {
49        let sheet = CssStyleSheet::new().unwrap();
50        sheet.replace_sync(include_str!("../../css/panel-tab.css"))
51            .unwrap();
52        sheet
53    };
54}
55
56/// Define the `<perspective-viewer-tab>` custom element once. It needs no
57/// lifecycle callbacks — it exists only to own a ShadowRoot into which each
58/// tab's contents are rendered (so the tab's structure CSS is encapsulated in
59/// its `adoptedStyleSheets` rather than injected into `document.head`).
60fn ensure_custom_element() {
61    ELEMENT_DEFINED.with(|defined| {
62        if !defined.get() {
63            define_panel_tab(TAB_TAG);
64            defined.set(true);
65        }
66    });
67}
68
69/// Adopt the shared tab stylesheet into `shadow_root.adoptedStyleSheets`
70/// (idempotent per root). Mirrors `StyleProvider`'s adopt-by-`Reflect`
71/// approach.
72fn adopt_sheet(shadow_root: &Element) {
73    let sheets = js_sys::Reflect::get(shadow_root.as_ref(), &"adoptedStyleSheets".into())
74        .unwrap()
75        .unchecked_into::<js_sys::Array>();
76
77    TAB_SHEET.with(|sheet| {
78        let sheet_val: &JsValue = sheet.as_ref();
79        if sheets.index_of(sheet_val, 0) < 0 {
80            sheets.push(sheet_val);
81        }
82    });
83}
84
85/// A panel's titlebar tab. The host is a `<perspective-viewer-tab>` custom
86/// element mounted in the viewer's **light DOM** and forwarded into its
87/// `<regular-layout-frame>` titlebar via a `<slot name="tab-{id}" slot="tab">`
88/// (rendered by `MainPanel`); the tab's *contents* live in the host's
89/// **ShadowRoot**, whose `adoptedStyleSheets` carry the structure CSS.
90#[derive(Properties, PartialEq)]
91pub struct PanelTabProps {
92    /// The `<perspective-viewer>` host element; the tab is attached here as a
93    /// light-DOM child (mirrors `renderer::activate`'s plugin mount).
94    pub viewer: HtmlElement,
95
96    /// The panel id. The tab is assigned to `slot="tab-{panel_id}"`.
97    pub panel_id: String,
98
99    /// The panel's title; falls back to the id when `None`.
100    pub title: Option<String>,
101
102    /// This panel's own `Session`, whose `stats_changed` the tab subscribes
103    /// to directly so a stats tick re-renders only this tab.
104    pub session: Session,
105
106    /// This panel's effective theme. Reflected onto the host's `theme`
107    /// attribute so the document theme rules (`perspective-viewer [theme="X"]`)
108    /// theme the tab per-panel via the native cascade.
109    pub theme: Option<String>,
110
111    /// `true` when this is the active panel (toolbar target / selected tab);
112    /// drives the active-tab styling.
113    pub active: bool,
114
115    /// `true` when this panel is *visible* — the front (selected) tab of its
116    /// stack, or a lone panel. Hidden panels are those at an unselected index
117    /// of a tab stack. Independent of `active`: every stack has a visible
118    /// panel, but only one panel in the whole layout is active.
119    pub visible: bool,
120
121    /// `true` when this panel is a master (filter-source) panel — shows the
122    /// broadcast badge to the left of the close button.
123    pub is_master: bool,
124
125    /// `true` when this is the host viewer's ONLY panel (one plugin child).
126    /// Reflected on the host as a `single`/`multi` class — the same
127    /// panel-count CSS hook `Renderer::stamp_active` stamps on the plugin —
128    /// e.g. the caret is hidden on a lone tab (see panel-tab.css).
129    pub single: bool,
130
131    /// `false` for a lone panel (which can't be closed to zero) — hides the
132    /// close button.
133    pub closable: bool,
134
135    /// `true` when the settings sidebar is open. When closed, the tab renders
136    /// an open-settings button *in place of* the close button — the only
137    /// affordance for opening the settings panel (there is deliberately none
138    /// at zero panels).
139    pub is_settings_open: bool,
140
141    /// `true` while this panel's close awaits its layout commit, which keeps
142    /// the tab rendered but inert.
143    #[prop_or_default]
144    pub closing: bool,
145
146    /// `false` for a lone panel — suppresses the tab rearrange-drag.
147    /// `<regular-layout-frame>` arms a drag from any `part="tab"` pointerdown,
148    /// but a lone panel has nowhere to drop, so the host `pointerdown` handler
149    /// stops the event before it reaches the frame (see `create`).
150    pub draggable: bool,
151
152    /// Select this panel in the layout (brings its frame forward within a stack
153    /// and activates it). Wired by `MainPanel` to `RegularLayout::select`.
154    pub on_select: Callback<String>,
155
156    /// Remove this panel from the layout. Wired by `MainPanel` to the root
157    /// `ClosePanel` message (which mutates the `Workspace` model first, then
158    /// syncs the slave `regular-layout` — NOT `RegularLayout::remove_panel`
159    /// directly; see the app-initiated-layout-change invariant).
160    pub on_close: Callback<String>,
161
162    /// Open the settings sidebar targeting this panel. Wired by `MainPanel` to
163    /// select this panel in the layout, activate it (so the sidebar binds its
164    /// engines), then toggle the sidebar open.
165    pub on_open_settings: Callback<String>,
166
167    /// Open the panel context menu at `(client_x, client_y)`. Wired here on the
168    /// tab host because the tab's content is a `create_portal` subtree, so its
169    /// events don't reach the frame's main-tree `oncontextmenu` (unlike the
170    /// imperatively-mounted plugin body).
171    pub on_context_menu: Callback<(String, f64, f64)>,
172
173    /// Commit a new title for this panel. Wired by `MainPanel` to this panel's
174    /// own [`Session::set_title`](crate::session::Session::set_title).
175    /// `(panel_id, new_title)`; `None` clears it back to the id fallback.
176    pub on_rename: Callback<(String, Option<String>)>,
177}
178
179/// Max gap (ms) between two tab pointerdowns to count as a double-click.
180const DBLCLICK_MS: f64 = 400.0;
181
182/// Shown in place of the title when a panel has no explicit title (rather than
183/// falling back to the table / plugin name).
184const TITLE_PLACEHOLDER: &str = "untitled";
185
186pub enum PanelTabMsg {
187    /// A pointerdown on the tab host. Selects the panel; and when it's the
188    /// second within [`DBLCLICK_MS`], enters title-edit mode.
189    PointerDown(f64),
190    Close,
191
192    /// The tab's open-settings button (shown while the settings sidebar is
193    /// closed, in the close button's place).
194    OpenSettings,
195    ContextMenu(f64, f64),
196
197    /// Track the live `<input>` value while editing (drives the auto-sizer).
198    EditInput(String),
199
200    /// Commit the edited title to the panel's session (blur / Enter).
201    CommitEdit,
202
203    /// Abandon the edit, restoring the previous title (Escape).
204    CancelEdit,
205
206    /// This panel's `Session::stats_changed` fired; re-read the stats.
207    StatsChanged,
208}
209
210pub struct PanelTab {
211    host: HtmlElement,
212    shadow_root: Element,
213    _pointerdown: Closure<dyn FnMut(PointerEvent)>,
214    _contextmenu: Closure<dyn FnMut(MouseEvent)>,
215    draggable: Rc<Cell<bool>>,
216
217    /// `timeStamp` (ms) of the last host pointerdown, for synthesizing
218    /// double-clicks.
219    last_pointerdown: f64,
220
221    /// `true` while the title is being edited (renders an `<input>`).
222    editing: bool,
223
224    /// Live edited value; controls the `<input>` and the auto-sizer width.
225    edit_value: String,
226
227    /// The edit `<input>`, for focusing on edit entry.
228    input_ref: NodeRef,
229
230    /// Focus + select-all the input on the next render after entering edit
231    /// mode.
232    focus_pending: bool,
233
234    /// The `theme` prop value last written to the host.
235    stamped_theme: Option<Option<String>>,
236
237    /// This panel's `Table`/`View` dimensions, rendered as the `RowsCounter`
238    /// after the title.
239    stats: Option<ViewStats>,
240
241    /// Subscription to `session.stats_changed`, armed only while the tab is
242    /// `visible`.
243    _stats_sub: Option<Subscription>,
244}
245
246impl PanelTab {
247    fn slot_name(panel_id: &str) -> String {
248        format!("tab-{}", panel_id)
249    }
250
251    /// Arm the `session.stats_changed` subscription and take a fresh stats
252    /// snapshot.
253    fn arm_stats(&mut self, ctx: &Context<Self>) {
254        let cb = ctx.link().callback(|_: ()| PanelTabMsg::StatsChanged);
255        self._stats_sub = Some(ctx.props().session.stats_changed.add_notify_listener(&cb));
256        self.stats = ctx.props().session.get_table_stats();
257    }
258
259    /// Drop the stats subscription and snapshot.
260    fn disarm_stats(&mut self) {
261        self._stats_sub = None;
262        self.stats = None;
263    }
264
265    /// Enter title-edit mode, seeding the input with the *real* title (empty
266    /// when `None`, not the id fallback shown in display mode). Ignores
267    /// re-entry so a click while editing doesn't clobber the in-progress
268    /// edit. Returns whether a re-render is needed.
269    fn begin_edit(&mut self, ctx: &Context<Self>) -> bool {
270        if self.editing {
271            return false;
272        }
273
274        self.editing = true;
275        self.focus_pending = true;
276        self.edit_value = ctx.props().title.clone().unwrap_or_default();
277        true
278    }
279
280    /// Reflect `active`/`visible`/`single` onto the host (not a vnode — so
281    /// its class is set imperatively). `visible` marks the front (selected)
282    /// tab of every stack, not just the single active panel; `single`/`multi`
283    /// reflect the host viewer's panel count. The shadow CSS keys off
284    /// `:host(.active)` / `:host(.visible)` / `:host(.single)`.
285    fn sync_class(&self, active: bool, visible: bool, single: bool, closing: bool) {
286        let mut class = Vec::with_capacity(4);
287        if active {
288            class.push("active");
289        }
290
291        if visible {
292            class.push("visible");
293        }
294
295        if closing {
296            class.push("closing");
297        }
298
299        class.push(if single { "single" } else { "multi" });
300        let _ = self.host.set_attribute("class", &class.join(" "));
301    }
302}
303
304impl Component for PanelTab {
305    type Message = PanelTabMsg;
306    type Properties = PanelTabProps;
307
308    fn create(ctx: &Context<Self>) -> Self {
309        ensure_custom_element();
310
311        let host: HtmlElement = global::document()
312            .create_element(TAB_TAG)
313            .unwrap()
314            .unchecked_into();
315
316        host.set_attribute("part", "tab").unwrap();
317        host.set_attribute("slot", &Self::slot_name(&ctx.props().panel_id))
318            .unwrap();
319
320        let init = ShadowRootInit::new(ShadowRootMode::Open);
321        let shadow_root = host
322            .shadow_root()
323            .unwrap_or_else(|| host.attach_shadow(&init).unwrap())
324            .unchecked_into::<Element>();
325
326        adopt_sheet(&shadow_root);
327        let link = ctx.link().clone();
328        let draggable = Rc::new(Cell::new(ctx.props().draggable));
329        let drag_flag = draggable.clone();
330        let pointerdown = Closure::wrap(Box::new(move |event: PointerEvent| {
331            if !drag_flag.get() {
332                event.stop_propagation();
333                event.prevent_default();
334            }
335
336            let ts = event.unchecked_ref::<Event>().time_stamp();
337            link.send_message(PanelTabMsg::PointerDown(ts));
338        }) as Box<dyn FnMut(PointerEvent)>);
339        let _ = host
340            .add_event_listener_with_callback("pointerdown", pointerdown.as_ref().unchecked_ref());
341
342        let link = ctx.link().clone();
343        let contextmenu = Closure::wrap(Box::new(move |event: MouseEvent| {
344            // Shift+right-click passes through to the native browser menu.
345            if event.shift_key() {
346                return;
347            }
348
349            event.prevent_default();
350            event.stop_propagation();
351            link.send_message(PanelTabMsg::ContextMenu(
352                event.client_x() as f64,
353                event.client_y() as f64,
354            ));
355        }) as Box<dyn FnMut(MouseEvent)>);
356        let _ = host
357            .add_event_listener_with_callback("contextmenu", contextmenu.as_ref().unchecked_ref());
358
359        let mut tab = Self {
360            host,
361            shadow_root,
362            _pointerdown: pointerdown,
363            _contextmenu: contextmenu,
364            draggable,
365            last_pointerdown: f64::NEG_INFINITY,
366            editing: false,
367            edit_value: String::new(),
368            input_ref: NodeRef::default(),
369            focus_pending: false,
370            stamped_theme: None,
371            stats: None,
372            _stats_sub: None,
373        };
374
375        if ctx.props().visible {
376            tab.arm_stats(ctx);
377        }
378
379        tab
380    }
381
382    fn changed(&mut self, ctx: &Context<Self>, old: &Self::Properties) -> bool {
383        if ctx.props().panel_id != old.panel_id {
384            let _ = self
385                .host
386                .set_attribute("slot", &Self::slot_name(&ctx.props().panel_id));
387        }
388
389        if !ctx.props().visible {
390            self.disarm_stats();
391        } else if self._stats_sub.is_none() || ctx.props().session != old.session {
392            self.arm_stats(ctx);
393        }
394
395        self.draggable.set(ctx.props().draggable);
396        true
397    }
398
399    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
400        let id = ctx.props().panel_id.clone();
401        match msg {
402            PanelTabMsg::PointerDown(ts) => {
403                ctx.props().on_select.emit(id);
404                let is_double = ts - self.last_pointerdown <= DBLCLICK_MS;
405                self.last_pointerdown = ts;
406                is_double && self.begin_edit(ctx)
407            },
408            PanelTabMsg::Close => {
409                ctx.props().on_close.emit(id);
410                false
411            },
412            PanelTabMsg::OpenSettings => {
413                ctx.props().on_open_settings.emit(id);
414                false
415            },
416            PanelTabMsg::ContextMenu(x, y) => {
417                ctx.props().on_context_menu.emit((id, x, y));
418                false
419            },
420            PanelTabMsg::EditInput(value) => {
421                self.edit_value = value;
422                true
423            },
424            PanelTabMsg::CommitEdit => {
425                if !self.editing {
426                    return false;
427                }
428
429                self.editing = false;
430                let value = self.edit_value.trim();
431                let title = (!value.is_empty()).then(|| value.to_owned());
432                ctx.props().on_rename.emit((id, title));
433                true
434            },
435            PanelTabMsg::CancelEdit => {
436                if !self.editing {
437                    return false;
438                }
439
440                self.editing = false;
441                true
442            },
443            PanelTabMsg::StatsChanged => {
444                let stats = ctx.props().session.get_table_stats();
445                let changed = stats != self.stats;
446                self.stats = stats;
447                changed
448            },
449        }
450    }
451
452    fn view(&self, ctx: &Context<Self>) -> Html {
453        let on_close = ctx.link().callback(|e: PointerEvent| {
454            e.stop_propagation();
455            PanelTabMsg::Close
456        });
457
458        let on_open_settings = ctx.link().callback(|e: PointerEvent| {
459            e.stop_propagation();
460            PanelTabMsg::OpenSettings
461        });
462
463        let title_html = if self.editing {
464            let oninput = ctx.link().callback(|e: InputEvent| {
465                let value = e
466                    .target()
467                    .map(|t| t.unchecked_into::<HtmlInputElement>().value())
468                    .unwrap_or_default();
469                PanelTabMsg::EditInput(value)
470            });
471            let onblur = ctx.link().callback(|_: FocusEvent| PanelTabMsg::CommitEdit);
472            let onkeydown = ctx
473                .link()
474                .batch_callback(|e: KeyboardEvent| match e.key().as_str() {
475                    "Enter" => vec![PanelTabMsg::CommitEdit],
476                    "Escape" => vec![PanelTabMsg::CancelEdit],
477                    _ => vec![],
478                });
479
480            let onpointerdown = ctx.link().batch_callback(|e: PointerEvent| {
481                e.stop_propagation();
482                Vec::<PanelTabMsg>::new()
483            });
484
485            html! {
486                <label class="psp-tab-title input-sizer" data-value={self.edit_value.clone()}>
487                    <input
488                        ref={self.input_ref.clone()}
489                        value={self.edit_value.clone()}
490                        {oninput}
491                        {onblur}
492                        {onkeydown}
493                        {onpointerdown}
494                    />
495                </label>
496            }
497        } else {
498            // The panel's explicit title, else a placeholder (NOT the table /
499            // plugin name) rendered in the inactive color (see panel-tab.css
500            // `.psp-tab-title.placeholder`).
501            match ctx.props().title.clone().filter(|t| !t.is_empty()) {
502                Some(title) => html! { <span class="psp-tab-title">{ title }</span> },
503                None => html! {
504                    <span class="psp-tab-title placeholder">{ TITLE_PLACEHOLDER }</span>
505                },
506            }
507        };
508
509        let content = html! {
510            <>
511                <span class="psp-tab-caret" />
512                // <span class="psp-tab-grip" />
513                { title_html }
514                if ctx.props().visible { <RowsCounter stats={self.stats.clone()} /> }
515                if ctx.props().is_master { <span class="psp-tab-master" /> }
516                if !ctx.props().is_settings_open {
517                    <button class="psp-tab-settings" onpointerdown={on_open_settings} />
518                } else if ctx.props().closable {
519                    <button class="psp-tab-close" onpointerdown={on_close} />
520                }
521            </>
522        };
523
524        yew::create_portal(content, self.shadow_root.clone())
525    }
526
527    fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
528        self.sync_class(
529            ctx.props().active,
530            ctx.props().visible,
531            ctx.props().single,
532            ctx.props().closing,
533        );
534        let theme = &ctx.props().theme;
535        if self.stamped_theme.as_ref() != Some(theme) {
536            match theme {
537                Some(theme) => {
538                    let _ = self.host.set_attribute("theme", theme);
539                },
540                None => {
541                    let _ = self.host.remove_attribute("theme");
542                },
543            }
544
545            self.stamped_theme = Some(theme.clone());
546        }
547
548        if !self.host.is_connected() {
549            let _ = ctx.props().viewer.append_child(&self.host);
550        }
551
552        // Focus + select-all once, on entry to edit mode.
553        if self.focus_pending {
554            self.focus_pending = false;
555            if let Some(input) = self.input_ref.cast::<HtmlInputElement>() {
556                let _ = input.focus();
557                input.select();
558            }
559        }
560    }
561
562    fn destroy(&mut self, ctx: &Context<Self>) {
563        if self.host.is_connected() {
564            let _ = ctx.props().viewer.remove_child(&self.host);
565        }
566    }
567}