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![ResizableHandle::new(i).into(), e.clone().into()]
329                } else {
330                    vec![e.clone().into()]
331                }
332            }))
333    }
334}
335
336#[derive(PartialEq, Clone)]
337pub struct ResizablePanel {
338    key: DiffKey,
339    initial_size: PanelSize,
340    min_size: Option<f32>,
341    children: Vec<Element>,
342    order: Option<usize>,
343}
344
345impl KeyExt for ResizablePanel {
346    fn write_key(&mut self) -> &mut DiffKey {
347        &mut self.key
348    }
349}
350
351impl ChildrenExt for ResizablePanel {
352    fn get_children(&mut self) -> &mut Vec<Element> {
353        &mut self.children
354    }
355}
356
357impl ResizablePanel {
358    pub fn new(initial_size: PanelSize) -> Self {
359        Self {
360            key: DiffKey::None,
361            initial_size,
362            min_size: None,
363            children: vec![],
364            order: None,
365        }
366    }
367
368    pub fn initial_size(mut self, initial_size: PanelSize) -> Self {
369        self.initial_size = initial_size;
370        self
371    }
372
373    /// Set the minimum size for this panel (in the same units as the panel's sizing mode).
374    pub fn min_size(mut self, min_size: impl Into<f32>) -> Self {
375        self.min_size = Some(min_size.into());
376        self
377    }
378
379    pub fn order(mut self, order: impl Into<usize>) -> Self {
380        self.order = Some(order.into());
381        self
382    }
383}
384
385impl Component for ResizablePanel {
386    fn render(&self) -> impl IntoElement {
387        let registry = use_consume::<Writable<ResizableContext>>();
388
389        let initial_value = self.initial_size.value();
390        let id = use_hook({
391            let mut registry = registry.clone();
392            move || {
393                let id = UseId::<ResizableContext>::get_in_hook();
394                let panel = Panel {
395                    initial_size: initial_value,
396                    size: initial_value,
397                    min_size: self.min_size.unwrap_or(initial_value * 0.25),
398                    sizing: self.initial_size,
399                    id,
400                };
401                registry.write().push_panel(panel, self.order);
402                id
403            }
404        });
405
406        use_drop({
407            let mut registry = registry.clone();
408            move || {
409                let _ = registry.write().remove_panel(id);
410            }
411        });
412
413        let registry = registry.read();
414        let index = registry
415            .panels
416            .iter()
417            .position(|e| e.id == id)
418            .unwrap_or_default();
419
420        let Panel { size, sizing, .. } = registry.panels[index];
421        let main_size = sizing.to_layout_size(size);
422
423        let (width, height) = match registry.direction {
424            Direction::Horizontal => (main_size, Size::fill()),
425            Direction::Vertical => (Size::fill(), main_size),
426        };
427
428        rect()
429            .a11y_role(AccessibilityRole::Pane)
430            .width(width)
431            .height(height)
432            .overflow(Overflow::Clip)
433            .children(self.children.clone())
434    }
435
436    fn render_key(&self) -> DiffKey {
437        self.key.clone().or(self.default_key())
438    }
439}
440
441/// Describes the current status of the Handle.
442#[derive(Debug, Default, PartialEq, Clone, Copy)]
443pub enum HandleStatus {
444    /// Default state.
445    #[default]
446    Idle,
447    /// Mouse is hovering the handle.
448    Hovering,
449}
450
451#[derive(PartialEq)]
452pub struct ResizableHandle {
453    panel_index: usize,
454    /// Theme override.
455    pub(crate) theme: Option<ResizableHandleThemePartial>,
456}
457
458impl ResizableHandle {
459    pub fn new(panel_index: usize) -> Self {
460        Self {
461            panel_index,
462            theme: None,
463        }
464    }
465}
466
467impl Component for ResizableHandle {
468    fn render(&self) -> impl IntoElement {
469        let ResizableHandleTheme {
470            background,
471            hover_background,
472            corner_radius,
473        } = get_theme!(
474            &self.theme,
475            ResizableHandleThemePreference,
476            "resizable_handle"
477        );
478        let mut size = use_state(Area::default);
479        let mut clicking = use_state(|| false);
480        let mut status = use_state(HandleStatus::default);
481        let registry = use_consume::<Writable<ResizableContext>>();
482        let container_size = use_consume::<State<Area>>();
483        let mut allow_resizing = use_state(|| false);
484
485        let panel_index = self.panel_index;
486        let direction = registry.read().direction;
487
488        use_drop(move || {
489            if *status.peek() == HandleStatus::Hovering {
490                Cursor::set(CursorIcon::default());
491            }
492        });
493
494        let cursor = match direction {
495            Direction::Horizontal => CursorIcon::ColResize,
496            _ => CursorIcon::RowResize,
497        };
498
499        let on_pointer_leave = move |_| {
500            *status.write() = HandleStatus::Idle;
501            if !clicking() {
502                Cursor::set(CursorIcon::default());
503            }
504        };
505
506        let on_pointer_enter = move |_| {
507            *status.write() = HandleStatus::Hovering;
508            Cursor::set(cursor);
509        };
510
511        let on_capture_global_pointer_move = {
512            let mut registry = registry;
513            move |e: Event<PointerEventData>| {
514                if *clicking.read() {
515                    e.prevent_default();
516
517                    if !*allow_resizing.read() {
518                        return;
519                    }
520
521                    let coords = e.global_location();
522                    let handle = size.read();
523                    let container = container_size.read();
524                    let mut registry = registry.write();
525
526                    let (pixel_displacement, container_axis_size) = match registry.direction {
527                        Direction::Horizontal => {
528                            (coords.x as f32 - handle.min_x(), container.width())
529                        }
530                        Direction::Vertical => {
531                            (coords.y as f32 - handle.min_y(), container.height())
532                        }
533                    };
534
535                    let changed_panels =
536                        registry.apply_resize(panel_index, pixel_displacement, container_axis_size);
537
538                    if changed_panels {
539                        allow_resizing.set(false);
540                    }
541                }
542            }
543        };
544
545        let on_pointer_down = move |e: Event<PointerEventData>| {
546            e.stop_propagation();
547            e.prevent_default();
548            clicking.set(true);
549        };
550
551        let on_global_pointer_press = move |_: Event<PointerEventData>| {
552            if *clicking.read() {
553                if *status.peek() != HandleStatus::Hovering {
554                    Cursor::set(CursorIcon::default());
555                }
556                clicking.set(false);
557            }
558        };
559
560        let handle_size = Size::px(ResizableContext::HANDLE_SIZE);
561        let (width, height) = match direction {
562            Direction::Horizontal => (handle_size, Size::fill()),
563            Direction::Vertical => (Size::fill(), handle_size),
564        };
565
566        let background = match *status.read() {
567            HandleStatus::Idle if !*clicking.read() => background,
568            _ => hover_background,
569        };
570
571        rect()
572            .width(width)
573            .height(height)
574            .background(background)
575            .corner_radius(corner_radius)
576            .on_sized(move |e: Event<SizedEventData>| {
577                size.set(e.area);
578                allow_resizing.set(true);
579            })
580            .on_pointer_down(on_pointer_down)
581            .on_global_pointer_press(on_global_pointer_press)
582            .on_pointer_enter(on_pointer_enter)
583            .on_capture_global_pointer_move(on_capture_global_pointer_move)
584            .on_pointer_leave(on_pointer_leave)
585    }
586}