freya_components/
resizable_container.rs

1use dioxus::prelude::*;
2use freya_core::{
3    custom_attributes::NodeReferenceLayout,
4    platform::CursorIcon,
5};
6use freya_elements::{
7    self as dioxus_elements,
8    events::MouseEvent,
9};
10use freya_hooks::{
11    use_applied_theme,
12    use_node_signal,
13    use_platform,
14    ResizableHandleTheme,
15    ResizableHandleThemeWith,
16};
17
18struct Panel {
19    pub size: f32,
20    pub min_size: f32,
21}
22
23enum ResizableItem {
24    Panel(Panel),
25    Handle,
26}
27
28impl ResizableItem {
29    /// Get the [Panel] of the [ResizableItem]. Will panic if called in a [ResizableItem::Handle].
30    fn panel(&self) -> &Panel {
31        match self {
32            Self::Panel(panel) => panel,
33            Self::Handle => panic!("Not a Panel"),
34        }
35    }
36
37    /// Try to get the mutable [Panel] of the [ResizableItem]. Will return [None] if called in a [ResizableItem::Handle].
38    fn try_panel_mut(&mut self) -> Option<&mut Panel> {
39        match self {
40            Self::Panel(panel) => Some(panel),
41            Self::Handle => None,
42        }
43    }
44}
45
46#[derive(Default)]
47struct ResizableContext {
48    pub registry: Vec<ResizableItem>,
49    pub direction: String,
50}
51
52/// Resizable container, used in combination with [ResizablePanel()] and [ResizableHandle()].
53///
54/// Example:
55///
56/// ```no_run
57/// # use freya::prelude::*;
58/// fn app() -> Element {
59///     rsx!(
60///         ResizableContainer {
61///             direction: "vertical",
62///             ResizablePanel {
63///                 initial_size: 50.0,
64///                 label {
65///                     "Panel 1"
66///                 }
67///             }
68///             ResizableHandle { }
69///             ResizablePanel {
70///                 initial_size: 50.0,
71///                 min_size: 30.0,
72///                 label {
73///                     "Panel 2"
74///                 }
75///             }
76///         }
77///     )
78/// }
79/// ```
80#[component]
81pub fn ResizableContainer(
82    /// Direction of the container, `vertical`/`horizontal`.
83    /// Default to `vertical`.
84    #[props(default = "vertical".to_string())]
85    direction: String,
86    /// Inner children for the [ResizableContainer()].
87    children: Element,
88) -> Element {
89    let (node_reference, size) = use_node_signal();
90    use_context_provider(|| size);
91
92    use_context_provider(|| {
93        Signal::new(ResizableContext {
94            direction: direction.clone(),
95            ..Default::default()
96        })
97    });
98
99    rsx!(
100        rect {
101            reference: node_reference,
102            direction: "{direction}",
103            width: "fill",
104            height: "fill",
105            content: "flex",
106            {children}
107        }
108    )
109}
110
111/// Resizable panel to be used in combination with [ResizableContainer()] and [ResizableHandle()].
112#[component]
113pub fn ResizablePanel(
114    /// Initial size in % for this panel. Default to `10`.
115    #[props(default = 10.)]
116    initial_size: f32, // TODO: Automatically assign the remaining space in the last element with unspecified size?
117    /// Minimum size in % for this panel. Default to `4`.
118    #[props(default = 4.)]
119    min_size: f32,
120    /// Inner children for the [ResizablePanel()].
121    children: Element,
122) -> Element {
123    let mut registry = use_context::<Signal<ResizableContext>>();
124
125    let index = use_hook(move || {
126        registry.write().registry.push(ResizableItem::Panel(Panel {
127            size: initial_size,
128            min_size,
129        }));
130        registry.peek().registry.len() - 1
131    });
132
133    let registry = registry.read();
134
135    let Panel { size, .. } = registry.registry[index].panel();
136
137    let (width, height) = match registry.direction.as_str() {
138        "horizontal" => (format!("flex({size})"), "fill".to_owned()),
139        _ => ("fill".to_owned(), format!("flex({size}")),
140    };
141
142    rsx!(
143        rect {
144            width: "{width}",
145            height: "{height}",
146            overflow: "clip",
147            {children}
148        }
149    )
150}
151
152/// Describes the current status of the Handle.
153#[derive(Debug, Default, PartialEq, Clone, Copy)]
154pub enum HandleStatus {
155    /// Default state.
156    #[default]
157    Idle,
158    /// Mouse is hovering the handle.
159    Hovering,
160}
161
162/// Resizable panel to be used in combination with [ResizableContainer()] and [ResizablePanel()].
163#[component]
164pub fn ResizableHandle(
165    /// Theme override.
166    theme: Option<ResizableHandleThemeWith>,
167) -> Element {
168    let ResizableHandleTheme {
169        background,
170        hover_background,
171    } = use_applied_theme!(&theme, resizable_handle);
172    let (node_reference, size) = use_node_signal();
173    let mut clicking = use_signal(|| false);
174    let mut status = use_signal(HandleStatus::default);
175    let mut registry = use_context::<Signal<ResizableContext>>();
176    let container_size = use_context::<ReadOnlySignal<NodeReferenceLayout>>();
177    let platform = use_platform();
178    let mut allow_resizing = use_signal(|| false);
179
180    use_memo(move || {
181        size.read();
182        allow_resizing.set(true);
183
184        // Only allow more resizing after the node layout has updated
185    });
186
187    use_drop(move || {
188        if *status.peek() == HandleStatus::Hovering {
189            platform.set_cursor(CursorIcon::default());
190        }
191    });
192
193    let index = use_hook(move || {
194        registry.write().registry.push(ResizableItem::Handle);
195        registry.peek().registry.len() - 1
196    });
197
198    let cursor = match registry.read().direction.as_str() {
199        "horizontal" => CursorIcon::ColResize,
200        _ => CursorIcon::RowResize,
201    };
202
203    let onmouseleave = move |_: MouseEvent| {
204        *status.write() = HandleStatus::Idle;
205        if !clicking() {
206            platform.set_cursor(CursorIcon::default());
207        }
208    };
209
210    let onmouseenter = move |e: MouseEvent| {
211        e.stop_propagation();
212        *status.write() = HandleStatus::Hovering;
213        platform.set_cursor(cursor);
214    };
215
216    let onmousemove = move |e: MouseEvent| {
217        if clicking() {
218            if !allow_resizing() {
219                return;
220            }
221
222            let coordinates = e.get_screen_coordinates();
223            let mut registry = registry.write();
224
225            let displacement_per: f32 = match registry.direction.as_str() {
226                "horizontal" => {
227                    let container_width = container_size.read().area.width();
228                    let displacement = coordinates.x as f32 - size.read().area.min_x();
229                    100. / container_width * displacement
230                }
231                _ => {
232                    let container_height = container_size.read().area.height();
233                    let displacement = coordinates.y as f32 - size.read().area.min_y();
234                    100. / container_height * displacement
235                }
236            };
237
238            let mut changed_panels = false;
239
240            if displacement_per >= 0. {
241                // Resizing to the right
242
243                let mut acc_per = 0.0;
244
245                // Resize panels to the right
246                for next_item in &mut registry.registry[index..].iter_mut() {
247                    if let Some(panel) = next_item.try_panel_mut() {
248                        let old_size = panel.size;
249                        let new_size = (panel.size - displacement_per).clamp(panel.min_size, 100.);
250
251                        if panel.size != new_size {
252                            changed_panels = true
253                        }
254
255                        panel.size = new_size;
256                        acc_per -= new_size - old_size;
257
258                        if old_size > panel.min_size {
259                            break;
260                        }
261                    }
262                }
263
264                // Resize panels to the left
265                for prev_item in &mut registry.registry[0..index].iter_mut().rev() {
266                    if let Some(panel) = prev_item.try_panel_mut() {
267                        let new_size = (panel.size + acc_per).clamp(panel.min_size, 100.);
268
269                        if panel.size != new_size {
270                            changed_panels = true
271                        }
272
273                        panel.size = new_size;
274                        break;
275                    }
276                }
277            } else {
278                // Resizing to the left
279
280                let mut acc_per = 0.0;
281
282                // Resize panels to the left
283                for prev_item in &mut registry.registry[0..index].iter_mut().rev() {
284                    if let Some(panel) = prev_item.try_panel_mut() {
285                        let old_size = panel.size;
286                        let new_size = (panel.size + displacement_per).clamp(panel.min_size, 100.);
287
288                        if panel.size != new_size {
289                            changed_panels = true
290                        }
291
292                        panel.size = new_size;
293                        acc_per += new_size - old_size;
294
295                        if old_size > panel.min_size {
296                            break;
297                        }
298                    }
299                }
300
301                // Resize panels to the right
302                for next_item in &mut registry.registry[index..].iter_mut() {
303                    if let Some(panel) = next_item.try_panel_mut() {
304                        let new_size = (panel.size - acc_per).clamp(panel.min_size, 100.);
305
306                        if panel.size != new_size {
307                            changed_panels = true
308                        }
309
310                        panel.size = new_size;
311                        break;
312                    }
313                }
314            }
315
316            if changed_panels {
317                allow_resizing.set(false);
318            }
319        }
320    };
321
322    let onmousedown = move |e: MouseEvent| {
323        e.stop_propagation();
324        clicking.set(true);
325    };
326
327    let onclick = move |_: MouseEvent| {
328        if clicking() {
329            if *status.peek() != HandleStatus::Hovering {
330                platform.set_cursor(CursorIcon::default());
331            }
332            clicking.set(false);
333        }
334    };
335
336    let (width, height) = match registry.read().direction.as_str() {
337        "horizontal" => ("4", "fill"),
338        _ => ("fill", "4"),
339    };
340
341    let background = match status() {
342        _ if clicking() => hover_background,
343        HandleStatus::Hovering => hover_background,
344        HandleStatus::Idle => background,
345    };
346
347    rsx!(rect {
348        reference: node_reference,
349        width: "{width}",
350        height: "{height}",
351        background: "{background}",
352        onmousedown,
353        onglobalclick: onclick,
354        onmouseenter,
355        onglobalmousemove: onmousemove,
356        onmouseleave,
357    })
358}
359
360#[cfg(test)]
361mod test {
362    use freya::prelude::*;
363    use freya_testing::prelude::*;
364
365    #[tokio::test]
366    pub async fn resizable_container() {
367        fn resizable_container_app() -> Element {
368            rsx!(
369                ResizableContainer {
370                    ResizablePanel {
371                        initial_size: 50.,
372                        label {
373                            "Panel 0"
374                        }
375                    }
376                    ResizableHandle { }
377                    ResizablePanel { // Panel 1
378                        initial_size: 50.,
379                        ResizableContainer {
380                            direction: "horizontal",
381                            ResizablePanel {
382                                initial_size: 33.33,
383                                label {
384                                    "Panel 2"
385                                }
386                            }
387                            ResizableHandle { }
388                            ResizablePanel {
389                                initial_size: 33.33,
390                                label {
391                                    "Panel 3"
392                                }
393                            }
394                            ResizableHandle { }
395                            ResizablePanel {
396                                initial_size: 33.33,
397                                label {
398                                    "Panel 4"
399                                }
400                            }
401                        }
402                    }
403                }
404            )
405        }
406
407        let mut utils = launch_test(resizable_container_app);
408        utils.wait_for_update().await;
409        let root = utils.root();
410
411        let container = root.get(0);
412        let panel_0 = container.get(0);
413        let panel_1 = container.get(2);
414        let panel_2 = panel_1.get(0).get(0);
415        let panel_3 = panel_1.get(0).get(2);
416        let panel_4 = panel_1.get(0).get(4);
417
418        assert_eq!(panel_0.layout().unwrap().area.height().round(), 248.0);
419        assert_eq!(panel_1.layout().unwrap().area.height().round(), 248.0);
420        assert_eq!(panel_2.layout().unwrap().area.width().round(), 164.0);
421        assert_eq!(panel_3.layout().unwrap().area.width().round(), 164.0);
422        assert_eq!(panel_4.layout().unwrap().area.width().round(), 164.0);
423
424        // Vertical
425        utils.push_event(TestEvent::Mouse {
426            name: EventName::MouseDown,
427            cursor: (100.0, 250.0).into(),
428            button: Some(MouseButton::Left),
429        });
430        utils.push_event(TestEvent::Mouse {
431            name: EventName::MouseMove,
432            cursor: (100.0, 200.0).into(),
433            button: Some(MouseButton::Left),
434        });
435        utils.push_event(TestEvent::Mouse {
436            name: EventName::MouseUp,
437            cursor: (0.0, 0.0).into(),
438            button: Some(MouseButton::Left),
439        });
440        utils.wait_for_update().await;
441
442        assert_eq!(panel_0.layout().unwrap().area.height().round(), 200.0); // 250 - 50
443        assert_eq!(panel_1.layout().unwrap().area.height().round(), 296.0); // 500 - 200 - 4
444
445        // Horizontal
446        utils.push_event(TestEvent::Mouse {
447            name: EventName::MouseDown,
448            cursor: (167.0, 300.0).into(),
449            button: Some(MouseButton::Left),
450        });
451        utils.push_event(TestEvent::Mouse {
452            name: EventName::MouseMove,
453            cursor: (187.0, 300.0).into(),
454            button: Some(MouseButton::Left),
455        });
456        utils.push_event(TestEvent::Mouse {
457            name: EventName::MouseUp,
458            cursor: (0.0, 0.0).into(),
459            button: Some(MouseButton::Left),
460        });
461        utils.wait_for_update().await;
462        utils.wait_for_update().await;
463        utils.wait_for_update().await;
464
465        assert_eq!(panel_2.layout().unwrap().area.width().round(), 187.0); // 167 + 20
466        assert_eq!(panel_3.layout().unwrap().area.width().round(), 141.0);
467    }
468}