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