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}