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