perspective_viewer/ui/containers/
split_panel.rs1use 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
106pub 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}