Skip to main content

perspective_viewer/components/containers/
split_panel.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::cmp::max;
14
15use perspective_js::utils::{ApiResult, global};
16use wasm_bindgen::JsCast;
17use wasm_bindgen::prelude::*;
18use web_sys::HtmlElement;
19use yew::html::Scope;
20use yew::prelude::*;
21
22#[derive(Properties, Default)]
23pub struct SplitPanelProps {
24    pub children: Children,
25
26    #[prop_or_default]
27    pub id: Option<String>,
28
29    #[prop_or_default]
30    pub orientation: Orientation,
31
32    /// Whether to OMIT children that render nothing (an empty fragment, or
33    /// any nesting of empty fragments — see [`is_empty_html`]) instead of
34    /// rendering them as empty panes. A skipped pane takes its divider with
35    /// it.
36    #[prop_or_default]
37    pub skip_empty: bool,
38
39    /// Should the child panels by wrapped in `<div>` elements?
40    #[prop_or_default]
41    pub no_wrap: bool,
42
43    /// Should the panels be rendered/sized in _reverse_ order?
44    #[prop_or_default]
45    pub reverse: bool,
46
47    #[prop_or_default]
48    pub on_reset: Option<Callback<()>>,
49
50    #[prop_or_default]
51    pub on_resize: Option<Callback<(i32, i32)>>,
52
53    #[prop_or_default]
54    pub on_resize_finished: Option<Callback<()>>,
55
56    #[prop_or_default]
57    pub initial_size: Option<i32>,
58
59    /// When `true`, a divider drag does NOT apply pane sizes directly:
60    /// `MoveResizing` only emits `on_resize` with the proposed dims, and pane
61    /// 0's committed size is driven exclusively by the controlled
62    /// [`Self::size`] prop — set by the parent when it is ready (e.g. after
63    /// pre-rendering panel content at the target size). This is the presize
64    /// gate for continuous divider drags (`PRESIZE_EVERYWHERE_PLAN.md`,
65    /// P1): geometry never outruns content. Only meaningful when the
66    /// resizable pane is child 0 (as in the viewer's `app_panel`).
67    #[prop_or_default]
68    pub deferred: bool,
69
70    /// Controlled committed size for pane 0 (deferred mode). Unlike
71    /// [`Self::initial_size`] (read once at `create`), changes to this prop
72    /// re-derive pane 0's style on every `changed`.
73    #[prop_or_default]
74    pub size: Option<i32>,
75}
76
77/// `true` when `node` renders NOTHING: an empty fragment, or a fragment of
78/// nothing but empty fragments. SEMANTIC, not literal — the `html!`
79/// if-syntax (and other wrappers) nests an empty branch inside another
80/// `VList` rather than yielding a bare `<></>`, which is why a literal
81/// `x != html! { <></> }` comparison is not a usable emptiness test (see
82/// the mechanism unit test). Deliberately conservative: `VText("")` and
83/// other node kinds are NOT considered empty.
84fn is_empty_html(node: &Html) -> bool {
85    match node {
86        Html::VList(list) => list.iter().all(is_empty_html),
87        _ => false,
88    }
89}
90
91/// The fixed-size pane style for a committed size (the complement pane
92/// flex-fills).
93fn size_style(orientation: Orientation, x: i32) -> String {
94    match orientation {
95        Orientation::Horizontal => {
96            format!("max-width:{x}px;min-width:{x}px;width:{x}px")
97        },
98        Orientation::Vertical => {
99            format!("max-height:{x}px;min-height:{x}px;height:{x}px")
100        },
101    }
102}
103
104impl SplitPanelProps {
105    fn validate(&self) -> bool {
106        !self.children.is_empty()
107    }
108}
109
110impl PartialEq for SplitPanelProps {
111    fn eq(&self, other: &Self) -> bool {
112        self.id == other.id
113            && self.children == other.children
114            && self.orientation == other.orientation
115            && self.reverse == other.reverse
116            && self.size == other.size
117            && self.deferred == other.deferred
118    }
119}
120
121pub enum SplitPanelMsg {
122    StartResizing(usize, i32, i32, HtmlElement),
123    MoveResizing(i32),
124    StopResizing,
125    Reset(usize),
126}
127
128/// A panel with 2 sub panels and a mouse-draggable divider which allows
129/// apportioning the panel's width.
130///
131/// # Examples
132///
133/// ```
134/// html! {
135///     <SplitPanel id="app_panel">
136///         <div id="A">
137///         <div id="B">
138///             <a href=".."></a>
139///         </div>
140///     </SplitPanel>
141/// }
142/// ```
143pub struct SplitPanel {
144    resize_state: Option<ResizingState>,
145    refs: Vec<NodeRef>,
146    styles: Vec<Option<String>>,
147    on_reset: Option<Callback<()>>,
148}
149
150impl Component for SplitPanel {
151    type Message = SplitPanelMsg;
152    type Properties = SplitPanelProps;
153
154    fn create(ctx: &Context<Self>) -> Self {
155        assert!(ctx.props().validate());
156        let len = ctx.props().children.len();
157        // cant just use vec![Default::default(); len] as it would
158        // use the same underlying NodeRef for each element.
159        let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
160
161        let mut styles = vec![Default::default(); len];
162        if let Some(x) = ctx.props().size.or(ctx.props().initial_size) {
163            styles[0] = Some(size_style(ctx.props().orientation, x));
164        }
165
166        Self {
167            resize_state: None,
168            refs,
169            styles,
170            on_reset: None,
171        }
172    }
173
174    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
175        match msg {
176            SplitPanelMsg::Reset(index) => {
177                self.styles[index] = None;
178                self.on_reset.clone_from(&ctx.props().on_reset);
179            },
180            SplitPanelMsg::StartResizing(index, client_offset, pointer_id, pointer_elem) => {
181                let elem = self.refs[index].cast::<HtmlElement>().unwrap();
182                let state =
183                    ResizingState::new(index, client_offset, ctx, &elem, pointer_id, pointer_elem);
184
185                self.resize_state = state.ok();
186            },
187            SplitPanelMsg::StopResizing => {
188                self.resize_state = None;
189                if let Some(cb) = &ctx.props().on_resize_finished {
190                    cb.emit(());
191                }
192            },
193            SplitPanelMsg::MoveResizing(client_offset) => {
194                if let Some(state) = self.resize_state.as_ref() {
195                    if let Some(ref cb) = ctx.props().on_resize {
196                        cb.emit(state.get_dimensions(client_offset));
197                    }
198
199                    // Deferred mode: propose only — the committed size arrives
200                    // back through the controlled `size` prop once the parent
201                    // has pre-rendered content at the target.
202                    if !ctx.props().deferred {
203                        self.styles[state.index] = state.get_style(client_offset);
204                    }
205                }
206            },
207        };
208        true
209    }
210
211    fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
212        if let Some(on_reset) = self.on_reset.take() {
213            on_reset.emit(());
214        }
215    }
216
217    fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
218        assert!(ctx.props().validate());
219        let new_len = ctx.props().children.len();
220        self.refs.resize_with(new_len, Default::default);
221        self.styles.resize(new_len, Default::default());
222
223        if let Some(state) = self.resize_state.as_ref() {
224            let skip_empty = ctx.props().skip_empty;
225            let still_visible = ctx
226                .props()
227                .children
228                .iter()
229                .enumerate()
230                .any(|(i, x)| i == state.index && (!skip_empty || !is_empty_html(&x)));
231
232            if !still_visible {
233                self.resize_state = None;
234            }
235        }
236
237        // Deferred mode: pane 0's style tracks the controlled `size` prop
238        // (`None` = natural width, e.g. after a divider double-click reset).
239        if ctx.props().deferred {
240            self.styles[0] = ctx
241                .props()
242                .size
243                .map(|x| size_style(ctx.props().orientation, x));
244        }
245
246        true
247    }
248
249    fn view(&self, ctx: &Context<Self>) -> Html {
250        let skip_empty = ctx.props().skip_empty;
251        let orientation = ctx.props().orientation;
252
253        // Pair each *visible* pane with its ORIGINAL child index and key every
254        // pane (and divider) by that stable identity — not its post-filter
255        // position. Toggling one pane in/out (e.g. the settings sidebar) then
256        // never shifts a sibling's key, so Yew reconciles the survivor in place
257        // rather than remounting it. A `MainPanel` remount here would tear down
258        // and recreate the embedded `<regular-layout>` and the `<slot>`s
259        // projecting the plugins — the bug this avoids. Dividers are independent
260        // keyed nodes for the same reason: a divider appearing before a pane must
261        // not alter that pane's own keyed subtree.
262        let panes = ctx
263            .props()
264            .children
265            .iter()
266            .enumerate()
267            .filter(|(_, x)| !skip_empty || !is_empty_html(x))
268            .collect::<Vec<_>>();
269
270        let last = panes.len().saturating_sub(1);
271        let mut nodes: Vec<Html> = Vec::with_capacity(panes.len() * 2);
272        let mut prev: Option<usize> = None;
273        for (pos, (i, x)) in panes.into_iter().enumerate() {
274            // A divider precedes every visible pane except the first; it resizes
275            // the *previous* visible pane (`prev`, an original index).
276            if let Some(p) = prev {
277                nodes.push(html! {
278                    <SplitPanelDivider
279                        key={format!("divider-{i}")}
280                        i={p}
281                        {orientation}
282                        link={ctx.link().clone()}
283                    />
284                });
285            }
286
287            // The last visible pane flex-fills (rendered bare, no width
288            // override); earlier panes are `SplitPanelChild`s that can carry a
289            // dragged size.
290            nodes.push(if pos == last {
291                html! { <key={i}>{ x }</> }
292            } else {
293                html! {
294                    <SplitPanelChild
295                        key={i}
296                        style={self.styles[i].clone()}
297                        ref_={self.refs[i].clone()}
298                    >
299                        { x }
300                    </SplitPanelChild>
301                }
302            });
303
304            prev = Some(i);
305        }
306
307        let mut classes = classes!("split-panel");
308        if orientation == Orientation::Vertical {
309            classes.push("orient-vertical");
310        }
311
312        if ctx.props().reverse {
313            classes.push("orient-reverse");
314        }
315
316        let contents = html! { <>{ for nodes.into_iter() }</> };
317        if ctx.props().no_wrap {
318            html! { { contents } }
319        } else {
320            html! { <div id={ctx.props().id.clone()} class={classes}>{ contents }</div> }
321        }
322    }
323}
324
325#[derive(Clone, Copy, Default, Eq, PartialEq)]
326pub enum Orientation {
327    #[default]
328    Horizontal,
329    Vertical,
330}
331
332#[derive(Properties)]
333struct SplitPanelDividerProps {
334    i: usize,
335    orientation: Orientation,
336    link: Scope<SplitPanel>,
337}
338
339impl PartialEq for SplitPanelDividerProps {
340    fn eq(&self, rhs: &Self) -> bool {
341        self.i == rhs.i && self.orientation == rhs.orientation
342    }
343}
344
345/// The resize handle for a `SplitPanel`.
346#[function_component(SplitPanelDivider)]
347fn split_panel_divider(props: &SplitPanelDividerProps) -> Html {
348    let orientation = props.orientation;
349    let i = props.i;
350    let link = props.link.clone();
351    let onmousedown = link.callback(move |event: PointerEvent| {
352        let target = event.target().unwrap().unchecked_into::<HtmlElement>();
353        let pointer_id = event.pointer_id();
354        let size = match orientation {
355            Orientation::Horizontal => event.client_x(),
356            Orientation::Vertical => event.client_y(),
357        };
358
359        SplitPanelMsg::StartResizing(i, size, pointer_id, target)
360    });
361
362    let ondblclick = props.link.callback(move |event: MouseEvent| {
363        event.prevent_default();
364        event.stop_propagation();
365        SplitPanelMsg::Reset(i)
366    });
367
368    // TODO Not sure why, but under some circumstances this can trigger a
369    // `dragstart`, leading to further drag events which cause perspective
370    // havoc.  `event.prevent_default()` in `onmousedown` alternatively fixes
371    // this, but also prevents this event from trigger focus-stealing e.g. from
372    // open dialogs.
373    let ondragstart = Callback::from(|event: DragEvent| event.prevent_default());
374
375    html! {
376        <>
377            <div
378                class="split-panel-divider"
379                {ondragstart}
380                onpointerdown={onmousedown}
381                {ondblclick}
382            />
383        </>
384    }
385}
386
387#[derive(Properties, PartialEq)]
388struct SplitPanelChildProps {
389    style: Option<String>,
390    ref_: NodeRef,
391    children: Children,
392}
393
394#[function_component(SplitPanelChild)]
395fn split_panel_child(props: &SplitPanelChildProps) -> Html {
396    let class = if props.style.is_some() {
397        classes!("split-panel-child", "is-width-override")
398    } else {
399        classes!("split-panel-child")
400    };
401    html! {
402        <div {class} ref={props.ref_.clone()} style={props.style.clone()}>
403            { props.children.iter().next().unwrap() }
404        </div>
405    }
406}
407
408/// The state for the `Resizing` action, including the `MouseEvent` callbacks
409/// and panel starting dimensions.
410struct ResizingState {
411    mousemove: Closure<dyn Fn(MouseEvent)>,
412    mouseup: Closure<dyn Fn(MouseEvent)>,
413    cursor: String,
414    index: usize,
415    start: i32,
416    total: i32,
417    alt: i32,
418    orientation: Orientation,
419    reverse: bool,
420    body_style: web_sys::CssStyleDeclaration,
421    pointer_id: i32,
422    pointer_elem: HtmlElement,
423}
424
425impl Drop for ResizingState {
426    /// On `drop`, we must remove these event listeners from the document
427    /// `body`. Without this, the `Closure` objects would not leak, but the
428    /// document will continue to call them, causing runtime exceptions.
429    fn drop(&mut self) {
430        let result: ApiResult<()> = (|| {
431            let mousemove = self.mousemove.as_ref().unchecked_ref();
432            global::body().remove_event_listener_with_callback("mousemove", mousemove)?;
433            let mouseup = self.mouseup.as_ref().unchecked_ref();
434            global::body().remove_event_listener_with_callback("mouseup", mouseup)?;
435            self.release_cursor()?;
436            Ok(())
437        })();
438
439        result.expect("Drop failed")
440    }
441}
442
443/// The minimum size a split panel child can be, including when overridden via
444/// user drag/drop.
445const MINIMUM_SIZE: i32 = 8;
446
447/// When the instantiated, capture the initial dimensions and create the
448/// MouseEvent callbacks.
449impl ResizingState {
450    pub fn new(
451        index: usize,
452        client_offset: i32,
453        ctx: &Context<SplitPanel>,
454        first_elem: &HtmlElement,
455        pointer_id: i32,
456        pointer_elem: HtmlElement,
457    ) -> ApiResult<Self> {
458        let orientation = ctx.props().orientation;
459        let reverse = ctx.props().reverse;
460        let split_panel = ctx.link();
461        let total = match orientation {
462            Orientation::Horizontal => first_elem.offset_width(),
463            Orientation::Vertical => first_elem.offset_height(),
464        };
465
466        let alt = match orientation {
467            Orientation::Horizontal => first_elem.offset_height(),
468            Orientation::Vertical => first_elem.offset_width(),
469        };
470
471        let mouseup = Closure::new({
472            let cb = split_panel.callback(|_| SplitPanelMsg::StopResizing);
473            move |x| cb.emit(x)
474        });
475
476        let mousemove = Closure::new({
477            let cb = split_panel.callback(move |event: MouseEvent| {
478                SplitPanelMsg::MoveResizing(match orientation {
479                    Orientation::Horizontal => event.client_x(),
480                    Orientation::Vertical => event.client_y(),
481                })
482            });
483
484            move |x| cb.emit(x)
485        });
486
487        let mut state = Self {
488            index,
489            cursor: "".to_owned(),
490            start: client_offset,
491            orientation,
492            reverse,
493            total,
494            alt,
495            body_style: global::body().style(),
496            mouseup,
497            mousemove,
498            pointer_id,
499            pointer_elem,
500        };
501
502        state.capture_cursor()?;
503        state.register_listeners()?;
504        Ok(state)
505    }
506
507    fn get_offset(&self, client_offset: i32) -> i32 {
508        let delta = if self.reverse {
509            self.start - client_offset
510        } else {
511            client_offset - self.start
512        };
513
514        max(MINIMUM_SIZE, self.total + delta)
515    }
516
517    pub fn get_style(&self, client_offset: i32) -> Option<String> {
518        let offset = self.get_offset(client_offset);
519        Some(match self.orientation {
520            Orientation::Horizontal => {
521                format!("max-width:{offset}px;min-width:{offset}px;width:{offset}px")
522            },
523            Orientation::Vertical => {
524                format!("max-height:{offset}px;min-height:{offset}px;height:{offset}px")
525            },
526        })
527    }
528
529    pub fn get_dimensions(&self, client_offset: i32) -> (i32, i32) {
530        let offset = self.get_offset(client_offset);
531        match self.orientation {
532            Orientation::Horizontal => (std::cmp::max(MINIMUM_SIZE, offset), self.alt),
533            Orientation::Vertical => (self.alt, std::cmp::max(MINIMUM_SIZE, offset)),
534        }
535    }
536
537    /// Adds the event listeners, the corollary of `Drop`.
538    fn register_listeners(&self) -> ApiResult<()> {
539        let mousemove = self.mousemove.as_ref().unchecked_ref();
540        global::body().add_event_listener_with_callback("mousemove", mousemove)?;
541        let mouseup = self.mouseup.as_ref().unchecked_ref();
542        Ok(global::body().add_event_listener_with_callback("mouseup", mouseup)?)
543    }
544
545    /// Helper functions capture and release the global cursor while dragging is
546    /// occurring.
547    fn capture_cursor(&mut self) -> ApiResult<()> {
548        self.pointer_elem.set_pointer_capture(self.pointer_id)?;
549        self.cursor = self.body_style.get_property_value("cursor")?;
550        self.body_style
551            .set_property("cursor", match self.orientation {
552                Orientation::Horizontal => "col-resize",
553                Orientation::Vertical => "row-resize",
554            })?;
555
556        Ok(())
557    }
558
559    /// " but for release
560    fn release_cursor(&self) -> ApiResult<()> {
561        self.pointer_elem.release_pointer_capture(self.pointer_id)?;
562        Ok(self.body_style.set_property("cursor", &self.cursor)?)
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    #[test]
571    fn html_if_empty_branch_is_not_literally_empty() {
572        let via_if: Html = html! { if false { <div /> } else { <></> } };
573        assert_ne!(via_if, html! { <></> });
574    }
575
576    #[test]
577    fn empty_fragment_is_empty() {
578        assert!(is_empty_html(&html! { <></> }));
579    }
580
581    #[test]
582    fn html_if_empty_branch_is_semantically_empty() {
583        assert!(is_empty_html(&html! { if false { <div/> } else { <></> } }));
584    }
585
586    #[test]
587    fn nested_empty_fragments_are_empty() {
588        assert!(is_empty_html(&html! { <><><></></></> }));
589    }
590
591    #[test]
592    fn tag_is_not_empty() {
593        assert!(!is_empty_html(&html! { <div/> }));
594    }
595
596    #[test]
597    fn fragment_containing_a_tag_is_not_empty() {
598        assert!(!is_empty_html(&html! { <><div/></> }));
599    }
600
601    #[test]
602    fn text_is_not_empty() {
603        assert!(!is_empty_html(&html! { { "" } }));
604    }
605}