Skip to main content

perspective_viewer/ui/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 class: Classes,
31
32    #[prop_or_default]
33    pub orientation: Orientation,
34
35    #[prop_or_default]
36    pub skip_empty: bool,
37
38    #[prop_or_default]
39    pub no_wrap: bool,
40
41    #[prop_or_default]
42    pub reverse: bool,
43
44    #[prop_or_default]
45    pub on_reset: Option<Callback<()>>,
46
47    #[prop_or_default]
48    pub on_resize: Option<Callback<(i32, i32)>>,
49
50    #[prop_or_default]
51    pub on_resize_finished: Option<Callback<()>>,
52
53    #[prop_or_default]
54    pub initial_size: Option<i32>,
55
56    #[prop_or_default]
57    pub deferred: bool,
58
59    #[prop_or_default]
60    pub size: Option<i32>,
61}
62
63fn is_empty_html(node: &Html) -> bool {
64    match node {
65        Html::VList(list) => list.iter().all(is_empty_html),
66        _ => false,
67    }
68}
69
70fn size_style(orientation: Orientation, x: i32) -> String {
71    match orientation {
72        Orientation::Horizontal => {
73            format!("max-width:{x}px;min-width:{x}px;width:{x}px")
74        },
75        Orientation::Vertical => {
76            format!("max-height:{x}px;min-height:{x}px;height:{x}px")
77        },
78    }
79}
80
81impl SplitPanelProps {
82    fn validate(&self) -> bool {
83        !self.children.is_empty()
84    }
85}
86
87impl PartialEq for SplitPanelProps {
88    fn eq(&self, other: &Self) -> bool {
89        self.id == other.id
90            && self.class == other.class
91            && self.children == other.children
92            && self.orientation == other.orientation
93            && self.reverse == other.reverse
94            && self.size == other.size
95            && self.deferred == other.deferred
96    }
97}
98
99pub enum SplitPanelMsg {
100    StartResizing(usize, i32, i32, HtmlElement),
101    MoveResizing(i32),
102    StopResizing,
103    Reset(usize),
104}
105
106/// A panel with 2 sub panels and a mouse-draggable divider which allows
107/// apportioning the panel's width.
108pub struct SplitPanel {
109    resize_state: Option<ResizingState>,
110    refs: Vec<NodeRef>,
111    styles: Vec<Option<String>>,
112    on_reset: Option<Callback<()>>,
113}
114
115impl Component for SplitPanel {
116    type Message = SplitPanelMsg;
117    type Properties = SplitPanelProps;
118
119    fn create(ctx: &Context<Self>) -> Self {
120        assert!(ctx.props().validate());
121        let len = ctx.props().children.len();
122        let refs = Vec::from_iter(std::iter::repeat_with(Default::default).take(len));
123
124        let mut styles = vec![Default::default(); len];
125        if let Some(x) = ctx.props().size.or(ctx.props().initial_size) {
126            styles[0] = Some(size_style(ctx.props().orientation, x));
127        }
128
129        Self {
130            resize_state: None,
131            refs,
132            styles,
133            on_reset: None,
134        }
135    }
136
137    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
138        match msg {
139            SplitPanelMsg::Reset(index) => {
140                if ctx.props().deferred {
141                    if let Some(cb) = &ctx.props().on_reset {
142                        cb.emit(());
143                    }
144
145                    return false;
146                }
147
148                self.styles[index] = None;
149                self.on_reset.clone_from(&ctx.props().on_reset);
150            },
151            SplitPanelMsg::StartResizing(index, client_offset, pointer_id, pointer_elem) => {
152                let elem = self.refs[index].cast::<HtmlElement>().unwrap();
153                let state =
154                    ResizingState::new(index, client_offset, ctx, &elem, pointer_id, pointer_elem);
155
156                self.resize_state = state.ok();
157            },
158            SplitPanelMsg::StopResizing => {
159                self.resize_state = None;
160                if let Some(cb) = &ctx.props().on_resize_finished {
161                    cb.emit(());
162                }
163            },
164            SplitPanelMsg::MoveResizing(client_offset) => {
165                if let Some(state) = self.resize_state.as_ref() {
166                    if let Some(ref cb) = ctx.props().on_resize {
167                        cb.emit(state.get_dimensions(client_offset));
168                    }
169
170                    if !ctx.props().deferred {
171                        self.styles[state.index] = state.get_style(client_offset);
172                    }
173                }
174            },
175        };
176        true
177    }
178
179    fn rendered(&mut self, _ctx: &Context<Self>, _first_render: bool) {
180        if let Some(on_reset) = self.on_reset.take() {
181            on_reset.emit(());
182        }
183    }
184
185    fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
186        assert!(ctx.props().validate());
187        let new_len = ctx.props().children.len();
188        self.refs.resize_with(new_len, Default::default);
189        self.styles.resize(new_len, Default::default());
190
191        if let Some(state) = self.resize_state.as_ref() {
192            let skip_empty = ctx.props().skip_empty;
193            let still_visible = ctx
194                .props()
195                .children
196                .iter()
197                .enumerate()
198                .any(|(i, x)| i == state.index && (!skip_empty || !is_empty_html(&x)));
199
200            if !still_visible {
201                self.resize_state = None;
202            }
203        }
204
205        if ctx.props().deferred {
206            self.styles[0] = ctx
207                .props()
208                .size
209                .map(|x| size_style(ctx.props().orientation, x));
210        }
211
212        true
213    }
214
215    fn view(&self, ctx: &Context<Self>) -> Html {
216        let skip_empty = ctx.props().skip_empty;
217        let orientation = ctx.props().orientation;
218
219        let panes = ctx
220            .props()
221            .children
222            .iter()
223            .enumerate()
224            .filter(|(_, x)| !skip_empty || !is_empty_html(x))
225            .collect::<Vec<_>>();
226
227        let last = panes.len().saturating_sub(1);
228        let mut nodes: Vec<Html> = Vec::with_capacity(panes.len() * 2);
229        let mut prev: Option<usize> = None;
230        for (pos, (i, x)) in panes.into_iter().enumerate() {
231            if let Some(p) = prev {
232                nodes.push(html! {
233                    <SplitPanelDivider
234                        key={format!("divider-{i}")}
235                        i={p}
236                        {orientation}
237                        link={ctx.link().clone()}
238                    />
239                });
240            }
241
242            nodes.push(if pos == last {
243                html! { <key={i}>{ x }</> }
244            } else {
245                html! {
246                    <SplitPanelChild
247                        key={i}
248                        style={self.styles[i].clone()}
249                        ref_={self.refs[i].clone()}
250                    >
251                        { x }
252                    </SplitPanelChild>
253                }
254            });
255
256            prev = Some(i);
257        }
258
259        let mut classes = classes!("split-panel");
260        classes.extend(ctx.props().class.clone());
261        if orientation == Orientation::Vertical {
262            classes.push("orient-vertical");
263        }
264
265        if ctx.props().reverse {
266            classes.push("orient-reverse");
267        }
268
269        let contents = html! { <>{ for nodes.into_iter() }</> };
270        if ctx.props().no_wrap {
271            html! { { contents } }
272        } else {
273            html! { <div id={ctx.props().id.clone()} class={classes}>{ contents }</div> }
274        }
275    }
276}
277
278#[derive(Clone, Copy, Default, Eq, PartialEq)]
279pub enum Orientation {
280    #[default]
281    Horizontal,
282    Vertical,
283}
284
285#[derive(Properties)]
286struct SplitPanelDividerProps {
287    i: usize,
288    orientation: Orientation,
289    link: Scope<SplitPanel>,
290}
291
292impl PartialEq for SplitPanelDividerProps {
293    fn eq(&self, rhs: &Self) -> bool {
294        self.i == rhs.i && self.orientation == rhs.orientation
295    }
296}
297
298#[function_component(SplitPanelDivider)]
299fn split_panel_divider(props: &SplitPanelDividerProps) -> Html {
300    let orientation = props.orientation;
301    let i = props.i;
302    let link = props.link.clone();
303    let onmousedown = link.callback(move |event: PointerEvent| {
304        let target = event.target().unwrap().unchecked_into::<HtmlElement>();
305        let pointer_id = event.pointer_id();
306        let size = match orientation {
307            Orientation::Horizontal => event.client_x(),
308            Orientation::Vertical => event.client_y(),
309        };
310
311        SplitPanelMsg::StartResizing(i, size, pointer_id, target)
312    });
313
314    let ondblclick = props.link.callback(move |event: MouseEvent| {
315        event.prevent_default();
316        event.stop_propagation();
317        SplitPanelMsg::Reset(i)
318    });
319
320    let ondragstart = Callback::from(|event: DragEvent| event.prevent_default());
321
322    html! {
323        <>
324            <div
325                class="split-panel-divider"
326                {ondragstart}
327                onpointerdown={onmousedown}
328                {ondblclick}
329            />
330        </>
331    }
332}
333
334#[derive(Properties, PartialEq)]
335struct SplitPanelChildProps {
336    style: Option<String>,
337    ref_: NodeRef,
338    children: Children,
339}
340
341#[function_component(SplitPanelChild)]
342fn split_panel_child(props: &SplitPanelChildProps) -> Html {
343    let class = if props.style.is_some() {
344        classes!("split-panel-child", "is-width-override")
345    } else {
346        classes!("split-panel-child")
347    };
348    html! {
349        <div {class} ref={props.ref_.clone()} style={props.style.clone()}>
350            { props.children.iter().next().unwrap() }
351        </div>
352    }
353}
354
355struct ResizingState {
356    mousemove: Closure<dyn Fn(MouseEvent)>,
357    mouseup: Closure<dyn Fn(MouseEvent)>,
358    cursor: String,
359    index: usize,
360    start: i32,
361    total: i32,
362    alt: i32,
363    orientation: Orientation,
364    reverse: bool,
365    body_style: web_sys::CssStyleDeclaration,
366    pointer_id: i32,
367    pointer_elem: HtmlElement,
368}
369
370impl Drop for ResizingState {
371    fn drop(&mut self) {
372        let result: ApiResult<()> = (|| {
373            let mousemove = self.mousemove.as_ref().unchecked_ref();
374            global::body().remove_event_listener_with_callback("mousemove", mousemove)?;
375            let mouseup = self.mouseup.as_ref().unchecked_ref();
376            global::body().remove_event_listener_with_callback("mouseup", mouseup)?;
377            self.release_cursor()?;
378            Ok(())
379        })();
380
381        result.expect("Drop failed")
382    }
383}
384
385const MINIMUM_SIZE: i32 = 8;
386
387impl ResizingState {
388    pub fn new(
389        index: usize,
390        client_offset: i32,
391        ctx: &Context<SplitPanel>,
392        first_elem: &HtmlElement,
393        pointer_id: i32,
394        pointer_elem: HtmlElement,
395    ) -> ApiResult<Self> {
396        let orientation = ctx.props().orientation;
397        let reverse = ctx.props().reverse;
398        let split_panel = ctx.link();
399        let total = match orientation {
400            Orientation::Horizontal => first_elem.offset_width(),
401            Orientation::Vertical => first_elem.offset_height(),
402        };
403
404        let alt = match orientation {
405            Orientation::Horizontal => first_elem.offset_height(),
406            Orientation::Vertical => first_elem.offset_width(),
407        };
408
409        let mouseup = Closure::new({
410            let cb = split_panel.callback(|_| SplitPanelMsg::StopResizing);
411            move |x| cb.emit(x)
412        });
413
414        let mousemove = Closure::new({
415            let cb = split_panel.callback(move |event: MouseEvent| {
416                SplitPanelMsg::MoveResizing(match orientation {
417                    Orientation::Horizontal => event.client_x(),
418                    Orientation::Vertical => event.client_y(),
419                })
420            });
421
422            move |x| cb.emit(x)
423        });
424
425        let mut state = Self {
426            index,
427            cursor: "".to_owned(),
428            start: client_offset,
429            orientation,
430            reverse,
431            total,
432            alt,
433            body_style: global::body().style(),
434            mouseup,
435            mousemove,
436            pointer_id,
437            pointer_elem,
438        };
439
440        state.capture_cursor()?;
441        state.register_listeners()?;
442        Ok(state)
443    }
444
445    fn get_offset(&self, client_offset: i32) -> i32 {
446        let delta = if self.reverse {
447            self.start - client_offset
448        } else {
449            client_offset - self.start
450        };
451
452        max(MINIMUM_SIZE, self.total + delta)
453    }
454
455    pub fn get_style(&self, client_offset: i32) -> Option<String> {
456        let offset = self.get_offset(client_offset);
457        Some(match self.orientation {
458            Orientation::Horizontal => {
459                format!("max-width:{offset}px;min-width:{offset}px;width:{offset}px")
460            },
461            Orientation::Vertical => {
462                format!("max-height:{offset}px;min-height:{offset}px;height:{offset}px")
463            },
464        })
465    }
466
467    pub fn get_dimensions(&self, client_offset: i32) -> (i32, i32) {
468        let offset = self.get_offset(client_offset);
469        match self.orientation {
470            Orientation::Horizontal => (std::cmp::max(MINIMUM_SIZE, offset), self.alt),
471            Orientation::Vertical => (self.alt, std::cmp::max(MINIMUM_SIZE, offset)),
472        }
473    }
474
475    fn register_listeners(&self) -> ApiResult<()> {
476        let mousemove = self.mousemove.as_ref().unchecked_ref();
477        global::body().add_event_listener_with_callback("mousemove", mousemove)?;
478        let mouseup = self.mouseup.as_ref().unchecked_ref();
479        Ok(global::body().add_event_listener_with_callback("mouseup", mouseup)?)
480    }
481
482    fn capture_cursor(&mut self) -> ApiResult<()> {
483        self.pointer_elem.set_pointer_capture(self.pointer_id)?;
484        self.cursor = self.body_style.get_property_value("cursor")?;
485        self.body_style
486            .set_property("cursor", match self.orientation {
487                Orientation::Horizontal => "col-resize",
488                Orientation::Vertical => "row-resize",
489            })?;
490
491        Ok(())
492    }
493
494    fn release_cursor(&self) -> ApiResult<()> {
495        self.pointer_elem.release_pointer_capture(self.pointer_id)?;
496        Ok(self.body_style.set_property("cursor", &self.cursor)?)
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn html_if_empty_branch_is_not_literally_empty() {
506        let via_if: Html = html! { if false { <div /> } else { <></> } };
507        assert_ne!(via_if, html! { <></> });
508    }
509
510    #[test]
511    fn empty_fragment_is_empty() {
512        assert!(is_empty_html(&html! { <></> }));
513    }
514
515    #[test]
516    fn html_if_empty_branch_is_semantically_empty() {
517        assert!(is_empty_html(&html! { if false { <div/> } else { <></> } }));
518    }
519
520    #[test]
521    fn nested_empty_fragments_are_empty() {
522        assert!(is_empty_html(&html! { <><><></></></> }));
523    }
524
525    #[test]
526    fn tag_is_not_empty() {
527        assert!(!is_empty_html(&html! { <div/> }));
528    }
529
530    #[test]
531    fn fragment_containing_a_tag_is_not_empty() {
532        assert!(!is_empty_html(&html! { <><div/></> }));
533    }
534
535    #[test]
536    fn text_is_not_empty() {
537        assert!(!is_empty_html(&html! { { "" } }));
538    }
539}