Skip to main content

freya_components/
resizable_container.rs

1use freya_core::prelude::*;
2use thiserror::Error;
3use torin::{
4    content::Content,
5    prelude::{
6        Area,
7        Direction,
8        Length,
9    },
10    size::Size,
11};
12
13use crate::{
14    define_theme,
15    get_theme,
16};
17
18define_theme! {
19    %[component]
20    pub ResizableHandle {
21        %[fields]
22        background: Color,
23        hover_background: Color,
24        corner_radius: CornerRadius,
25    }
26}
27
28/// Sizing mode for a resizable panel.
29#[derive(PartialEq, Clone, Copy, Debug)]
30pub enum PanelSize {
31    /// Fixed pixel size.
32    Pixels(Length),
33    /// Proportional flex weight distributed among other percentage panels.
34    Percentage(Length),
35}
36
37impl PanelSize {
38    pub fn px(v: f32) -> Self {
39        Self::Pixels(Length::new(v))
40    }
41
42    pub fn percent(v: f32) -> Self {
43        Self::Percentage(Length::new(v))
44    }
45
46    pub fn value(&self) -> f32 {
47        match self {
48            Self::Pixels(v) | Self::Percentage(v) => v.get(),
49        }
50    }
51
52    /// Convert a raw size value to the appropriate layout [Size].
53    fn to_layout_size(self, value: f32) -> Size {
54        match self {
55            Self::Pixels(_) => Size::px(value),
56            Self::Percentage(_) => Size::flex(value),
57        }
58    }
59
60    /// The upper bound for this sizing mode.
61    fn max_size(&self) -> f32 {
62        match self {
63            Self::Pixels(_) => f32::MAX,
64            Self::Percentage(_) => 100.,
65        }
66    }
67
68    /// Scale factor to convert between pixels and this panel's unit system.
69    fn flex_scale(&self, flex_factor: f32) -> f32 {
70        match self {
71            Self::Pixels(_) => 1.0,
72            Self::Percentage(_) => flex_factor,
73        }
74    }
75}
76
77#[derive(Error, Debug)]
78pub enum ResizableError {
79    #[error("Panel does not exist")]
80    PanelNotFound,
81}
82
83#[derive(Clone, Copy, Debug)]
84pub struct Panel {
85    pub size: f32,
86    pub initial_size: f32,
87    pub min_size: f32,
88    pub sizing: PanelSize,
89    pub id: usize,
90}
91
92#[derive(Default)]
93pub struct ResizableContext {
94    pub panels: Vec<Panel>,
95    pub direction: Direction,
96}
97
98impl ResizableContext {
99    pub const HANDLE_SIZE: f32 = 4.0;
100
101    pub fn direction(&self) -> Direction {
102        self.direction
103    }
104
105    pub fn panels(&mut self) -> &mut Vec<Panel> {
106        &mut self.panels
107    }
108
109    pub fn push_panel(&mut self, panel: Panel, order: Option<usize>) {
110        // Only redistribute among percentage panels
111        if matches!(panel.sizing, PanelSize::Percentage(_)) {
112            let mut buffer = panel.size;
113
114            for panel in self
115                .panels
116                .iter_mut()
117                .filter(|p| matches!(p.sizing, PanelSize::Percentage(_)))
118            {
119                let resized_sized = (panel.initial_size - panel.size).min(buffer);
120
121                if resized_sized >= 0. {
122                    panel.size = (panel.size - resized_sized).max(panel.min_size);
123                    let new_resized_sized = panel.initial_size - panel.size;
124                    buffer -= new_resized_sized;
125                }
126            }
127        }
128
129        match order {
130            Some(order) if order < self.panels.len() => self.panels.insert(order, panel),
131            _ => self.panels.push(panel),
132        }
133    }
134
135    pub fn remove_panel(&mut self, id: usize) -> Result<(), ResizableError> {
136        let removed_panel = self
137            .panels
138            .iter()
139            .copied()
140            .find(|p| p.id == id)
141            .ok_or(ResizableError::PanelNotFound)?;
142        self.panels.retain(|e| e.id != id);
143
144        // Only redistribute among percentage panels
145        if matches!(removed_panel.sizing, PanelSize::Percentage(_)) {
146            let mut buffer = removed_panel.size;
147
148            for panel in self
149                .panels
150                .iter_mut()
151                .filter(|p| matches!(p.sizing, PanelSize::Percentage(_)))
152            {
153                let resized_sized = (panel.initial_size - panel.size).min(buffer);
154
155                panel.size = (panel.size + resized_sized).max(panel.min_size);
156                let new_resized_sized = panel.initial_size - panel.size;
157                buffer -= new_resized_sized;
158            }
159        }
160
161        Ok(())
162    }
163
164    pub fn apply_resize(
165        &mut self,
166        panel_index: usize,
167        pixel_distance: f32,
168        container_size: f32,
169    ) -> bool {
170        let mut changed_panels = false;
171
172        // Precompute conversion factor between pixels and flex weight
173        let handle_space = self.panels.len().saturating_sub(1) as f32 * Self::HANDLE_SIZE;
174        let (px_total, flex_total) =
175            self.panels
176                .iter()
177                .fold((0.0, 0.0), |(px, flex): (f32, f32), p| match p.sizing {
178                    PanelSize::Pixels(_) => (px + p.size, flex),
179                    PanelSize::Percentage(_) => (px, flex + p.size),
180                });
181        let flex_factor = flex_total / (container_size - px_total - handle_space).max(1.0);
182
183        let abs_distance = pixel_distance.abs();
184        let (behind_range, forward_range) = if pixel_distance >= 0. {
185            (0..panel_index, panel_index..self.panels.len())
186        } else {
187            (panel_index..self.panels.len(), 0..panel_index)
188        };
189
190        let mut acc_pixels = 0.0;
191
192        // Shrink forward panels
193        for panel in self.panels[forward_range].iter_mut() {
194            let old_size = panel.size;
195            let scale = panel.sizing.flex_scale(flex_factor);
196            let new_size =
197                (panel.size - abs_distance * scale).clamp(panel.min_size, panel.sizing.max_size());
198            changed_panels |= panel.size != new_size;
199            panel.size = new_size;
200            acc_pixels -= (new_size - old_size) / scale.max(f32::MIN_POSITIVE);
201
202            if old_size > panel.min_size {
203                break;
204            }
205        }
206
207        // Grow behind panel
208        if let Some(panel) = self.panels[behind_range].last_mut() {
209            let scale = panel.sizing.flex_scale(flex_factor);
210            let new_size =
211                (panel.size + acc_pixels * scale).clamp(panel.min_size, panel.sizing.max_size());
212            changed_panels |= panel.size != new_size;
213            panel.size = new_size;
214        }
215
216        changed_panels
217    }
218
219    pub fn reset(&mut self) {
220        for panel in &mut self.panels {
221            panel.size = panel.initial_size;
222        }
223    }
224}
225
226/// A container with resizable panels.
227///
228/// # Example
229///
230/// ```rust
231/// # use freya::prelude::*;
232/// fn app() -> impl IntoElement {
233///     ResizableContainer::new()
234///         .panel(ResizablePanel::new(PanelSize::percent(50.)).child("Panel 1"))
235///         .panel(ResizablePanel::new(PanelSize::percent(50.)).child("Panel 2"))
236/// }
237/// # use freya_testing::prelude::*;
238/// # launch_doc(|| {
239/// #   rect().center().expanded().child(
240/// #       ResizableContainer::new()
241/// #           .panel(ResizablePanel::new(PanelSize::percent(50.)).child("Panel 1"))
242/// #           .panel(ResizablePanel::new(PanelSize::percent(50.)).child("Panel 2"))
243/// #   )
244/// # }, "./images/gallery_resizable_container.png").render();
245/// ```
246///
247/// # Preview
248/// ![ResizableContainer Preview][resizable_container]
249#[cfg_attr(feature = "docs",
250    doc = embed_doc_image::embed_image!("resizable_container", "images/gallery_resizable_container.png"),
251)]
252#[derive(PartialEq, Clone)]
253pub struct ResizableContainer {
254    direction: Direction,
255    panels: Vec<ResizablePanel>,
256    controller: Option<Writable<ResizableContext>>,
257}
258
259impl Default for ResizableContainer {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265impl ResizableContainer {
266    pub fn new() -> Self {
267        Self {
268            direction: Direction::Vertical,
269            panels: vec![],
270            controller: None,
271        }
272    }
273
274    pub fn direction(mut self, direction: Direction) -> Self {
275        self.direction = direction;
276        self
277    }
278
279    pub fn panel(mut self, panel: impl Into<Option<ResizablePanel>>) -> Self {
280        if let Some(panel) = panel.into() {
281            self.panels.push(panel);
282        }
283        self
284    }
285
286    pub fn panels_iter(mut self, panels: impl Iterator<Item = ResizablePanel>) -> Self {
287        self.panels.extend(panels);
288        self
289    }
290
291    pub fn controller(mut self, controller: impl Into<Writable<ResizableContext>>) -> Self {
292        self.controller = Some(controller.into());
293        self
294    }
295}
296
297impl Component for ResizableContainer {
298    fn render(&self) -> impl IntoElement {
299        let mut size = use_state(Area::default);
300        use_provide_context(|| size);
301
302        let direction = use_reactive(&self.direction);
303        use_provide_context(|| {
304            self.controller.clone().unwrap_or_else(|| {
305                let mut state = State::create(ResizableContext {
306                    direction: self.direction,
307                    ..Default::default()
308                });
309
310                Effect::create_sync_with_gen(move |current_gen| {
311                    let direction = direction();
312                    if current_gen > 0 {
313                        state.write().direction = direction;
314                    }
315                });
316
317                state.into_writable()
318            })
319        });
320
321        rect()
322            .direction(self.direction)
323            .on_sized(move |e: Event<SizedEventData>| size.set(e.area))
324            .expanded()
325            .content(Content::flex())
326            .children(self.panels.iter().enumerate().flat_map(|(i, e)| {
327                if i > 0 {
328                    vec![
329                        ResizableHandle::new(i).into_element(),
330                        e.clone().into_element(),
331                    ]
332                } else {
333                    vec![e.clone().into_element()]
334                }
335            }))
336    }
337}
338
339#[derive(PartialEq, Clone)]
340pub struct ResizablePanel {
341    key: DiffKey,
342    initial_size: PanelSize,
343    min_size: Option<f32>,
344    children: Vec<Element>,
345    order: Option<usize>,
346}
347
348impl KeyExt for ResizablePanel {
349    fn write_key(&mut self) -> &mut DiffKey {
350        &mut self.key
351    }
352}
353
354impl ChildrenExt for ResizablePanel {
355    fn get_children(&mut self) -> &mut Vec<Element> {
356        &mut self.children
357    }
358}
359
360impl ResizablePanel {
361    pub fn new(initial_size: PanelSize) -> Self {
362        Self {
363            key: DiffKey::None,
364            initial_size,
365            min_size: None,
366            children: vec![],
367            order: None,
368        }
369    }
370
371    pub fn initial_size(mut self, initial_size: PanelSize) -> Self {
372        self.initial_size = initial_size;
373        self
374    }
375
376    /// Set the minimum size for this panel (in the same units as the panel's sizing mode).
377    pub fn min_size(mut self, min_size: f32) -> Self {
378        self.min_size = Some(min_size);
379        self
380    }
381
382    pub fn order(mut self, order: impl Into<usize>) -> Self {
383        self.order = Some(order.into());
384        self
385    }
386}
387
388impl Component for ResizablePanel {
389    fn render(&self) -> impl IntoElement {
390        let registry = use_consume::<Writable<ResizableContext>>();
391
392        let initial_value = self.initial_size.value();
393        let id = use_hook({
394            let mut registry = registry.clone();
395            move || {
396                let id = UseId::<ResizableContext>::get_in_hook();
397                let panel = Panel {
398                    initial_size: initial_value,
399                    size: initial_value,
400                    min_size: self.min_size.unwrap_or(initial_value * 0.25),
401                    sizing: self.initial_size,
402                    id,
403                };
404                registry.write().push_panel(panel, self.order);
405                id
406            }
407        });
408
409        use_drop({
410            let mut registry = registry.clone();
411            move || {
412                let _ = registry.write().remove_panel(id);
413            }
414        });
415
416        let registry = registry.read();
417        let index = registry
418            .panels
419            .iter()
420            .position(|e| e.id == id)
421            .unwrap_or_default();
422
423        let Panel { size, sizing, .. } = registry.panels[index];
424        let main_size = sizing.to_layout_size(size);
425
426        let (width, height) = match registry.direction {
427            Direction::Horizontal => (main_size, Size::fill()),
428            Direction::Vertical => (Size::fill(), main_size),
429        };
430
431        rect()
432            .a11y_role(AccessibilityRole::Pane)
433            .width(width)
434            .height(height)
435            .overflow(Overflow::Clip)
436            .children(self.children.clone())
437    }
438
439    fn render_key(&self) -> DiffKey {
440        self.key.clone().or(self.default_key())
441    }
442}
443
444/// Describes the current status of the Handle.
445#[derive(Debug, Default, PartialEq, Clone, Copy)]
446pub enum HandleStatus {
447    /// Default state.
448    #[default]
449    Idle,
450    /// Mouse is hovering the handle.
451    Hovering,
452}
453
454#[derive(PartialEq)]
455pub struct ResizableHandle {
456    panel_index: usize,
457    /// Theme override.
458    pub(crate) theme: Option<ResizableHandleThemePartial>,
459}
460
461impl ResizableHandle {
462    pub fn new(panel_index: usize) -> Self {
463        Self {
464            panel_index,
465            theme: None,
466        }
467    }
468}
469
470impl Component for ResizableHandle {
471    fn render(&self) -> impl IntoElement {
472        let ResizableHandleTheme {
473            background,
474            hover_background,
475            corner_radius,
476        } = get_theme!(
477            &self.theme,
478            ResizableHandleThemePreference,
479            "resizable_handle"
480        );
481        let mut size = use_state(Area::default);
482        let mut clicking = use_state(|| false);
483        let mut status = use_state(HandleStatus::default);
484        let registry = use_consume::<Writable<ResizableContext>>();
485        let container_size = use_consume::<State<Area>>();
486        let mut allow_resizing = use_state(|| false);
487
488        let panel_index = self.panel_index;
489        let direction = registry.read().direction;
490
491        use_drop(move || {
492            if *status.peek() == HandleStatus::Hovering {
493                Cursor::set(CursorIcon::default());
494            }
495        });
496
497        let cursor = match direction {
498            Direction::Horizontal => CursorIcon::ColResize,
499            _ => CursorIcon::RowResize,
500        };
501
502        let on_pointer_leave = move |_| {
503            *status.write() = HandleStatus::Idle;
504            if !clicking() {
505                Cursor::set(CursorIcon::default());
506            }
507        };
508
509        let on_pointer_enter = move |_| {
510            *status.write() = HandleStatus::Hovering;
511            Cursor::set(cursor);
512        };
513
514        let on_capture_global_pointer_move = {
515            let mut registry = registry;
516            move |e: Event<PointerEventData>| {
517                if *clicking.read() {
518                    e.prevent_default();
519
520                    if !*allow_resizing.read() {
521                        return;
522                    }
523
524                    let coords = e.global_location();
525                    let handle = size.read();
526                    let container = container_size.read();
527                    let mut registry = registry.write();
528
529                    let (pixel_displacement, container_axis_size) = match registry.direction {
530                        Direction::Horizontal => {
531                            (coords.x as f32 - handle.min_x(), container.width())
532                        }
533                        Direction::Vertical => {
534                            (coords.y as f32 - handle.min_y(), container.height())
535                        }
536                    };
537
538                    let changed_panels =
539                        registry.apply_resize(panel_index, pixel_displacement, container_axis_size);
540
541                    if changed_panels {
542                        allow_resizing.set(false);
543                    }
544                }
545            }
546        };
547
548        let on_pointer_down = move |e: Event<PointerEventData>| {
549            if !e.data().is_primary() {
550                return;
551            }
552            e.stop_propagation();
553            e.prevent_default();
554            clicking.set(true);
555        };
556
557        let on_global_pointer_press = move |_: Event<PointerEventData>| {
558            if *clicking.read() {
559                if *status.peek() != HandleStatus::Hovering {
560                    Cursor::set(CursorIcon::default());
561                }
562                clicking.set(false);
563            }
564        };
565
566        let handle_size = Size::px(ResizableContext::HANDLE_SIZE);
567        let (width, height) = match direction {
568            Direction::Horizontal => (handle_size, Size::fill()),
569            Direction::Vertical => (Size::fill(), handle_size),
570        };
571
572        let background = match *status.read() {
573            HandleStatus::Idle if !*clicking.read() => background,
574            _ => hover_background,
575        };
576
577        rect()
578            .width(width)
579            .height(height)
580            .background(background)
581            .corner_radius(corner_radius)
582            .on_sized(move |e: Event<SizedEventData>| {
583                size.set(e.area);
584                allow_resizing.set(true);
585            })
586            .on_pointer_down(on_pointer_down)
587            .on_global_pointer_press(on_global_pointer_press)
588            .on_pointer_enter(on_pointer_enter)
589            .on_capture_global_pointer_move(on_capture_global_pointer_move)
590            .on_pointer_leave(on_pointer_leave)
591    }
592}