Skip to main content

dioxus_ui_system/organisms/
resizable.rs

1//! Resizable organism component
2//!
3//! A split-pane component with draggable resize handles.
4//! Supports both horizontal and vertical resizing with mouse and touch input.
5
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Direction of the resizable panel group
11#[derive(Clone, Copy, PartialEq, Default)]
12pub enum Direction {
13    /// Horizontal layout (panels side by side)
14    #[default]
15    Horizontal,
16    /// Vertical layout (panels stacked)
17    Vertical,
18}
19
20impl Direction {
21    /// Get the cursor style for this direction
22    fn cursor(self) -> &'static str {
23        match self {
24            Direction::Horizontal => "col-resize",
25            Direction::Vertical => "row-resize",
26        }
27    }
28}
29
30/// Panel configuration
31#[derive(Clone, PartialEq)]
32struct PanelConfig {
33    default_size: Option<f32>,
34    min_size: Option<f32>,
35    max_size: Option<f32>,
36}
37
38impl Default for PanelConfig {
39    fn default() -> Self {
40        Self {
41            default_size: None,
42            min_size: None,
43            max_size: None,
44        }
45    }
46}
47
48/// Resizable state context
49#[derive(Clone, Copy)]
50struct ResizableContext {
51    direction: Signal<Direction>,
52    panel_configs: Signal<Vec<PanelConfig>>,
53    panel_sizes: Signal<Vec<f32>>,
54    dragging: Signal<bool>,
55    active_handle: Signal<Option<usize>>,
56    drag_start_pos: Signal<f32>,
57    drag_start_sizes: Signal<Vec<f32>>,
58    panel_count: Signal<usize>,
59}
60
61impl ResizableContext {
62    fn new(direction: Direction) -> Self {
63        Self {
64            direction: use_signal(|| direction),
65            panel_configs: use_signal(Vec::new),
66            panel_sizes: use_signal(Vec::new),
67            dragging: use_signal(|| false),
68            active_handle: use_signal(|| None),
69            drag_start_pos: use_signal(|| 0.0),
70            drag_start_sizes: use_signal(Vec::new),
71            panel_count: use_signal(|| 0),
72        }
73    }
74}
75
76/// Calculate initial sizes for all panels
77fn calculate_sizes(configs: &[PanelConfig]) -> Vec<f32> {
78    let panel_count = configs.len();
79
80    if panel_count == 0 {
81        return Vec::new();
82    }
83
84    let mut sizes = vec![0.0; panel_count];
85    let mut assigned = 0.0;
86    let mut unassigned_indices = Vec::new();
87
88    for (i, config) in configs.iter().enumerate() {
89        if let Some(default) = config.default_size {
90            sizes[i] = default;
91            assigned += default;
92        } else {
93            unassigned_indices.push(i);
94        }
95    }
96
97    if !unassigned_indices.is_empty() {
98        let remaining = (100.0 - assigned).max(10.0);
99        let per_panel = remaining / unassigned_indices.len() as f32;
100        for &i in &unassigned_indices {
101            sizes[i] = per_panel;
102        }
103    }
104
105    let total: f32 = sizes.iter().sum();
106    if total > 0.0 {
107        for size in &mut sizes {
108            *size = (*size / total) * 100.0;
109        }
110    }
111
112    sizes
113}
114
115/// Resizable panel group properties
116#[derive(Props, Clone, PartialEq)]
117pub struct ResizablePanelGroupProps {
118    /// Direction of the panel group
119    #[props(default)]
120    pub direction: Direction,
121    /// Child elements (ResizablePanel and ResizableHandle components)
122    pub children: Element,
123    /// Optional CSS class
124    #[props(default)]
125    pub class: Option<String>,
126}
127
128/// Container component for resizable panels
129#[component]
130pub fn ResizablePanelGroup(props: ResizablePanelGroupProps) -> Element {
131    let _theme = use_theme();
132    let direction = props.direction;
133
134    let ctx = ResizableContext::new(direction);
135    use_context_provider(|| ctx);
136
137    let container_style = use_style(move |_t| {
138        let base = Style::new()
139            .w_full()
140            .h_full()
141            .overflow_hidden()
142            .select_none();
143
144        match direction {
145            Direction::Horizontal => base.flex().flex_row(),
146            Direction::Vertical => base.flex().flex_col(),
147        }
148        .build()
149    });
150
151    let class = props.class.unwrap_or_default();
152
153    rsx! {
154        div {
155            class: "resizable-panel-group {class}",
156            style: "{container_style}",
157            {props.children}
158        }
159    }
160}
161
162/// Resizable panel properties
163#[derive(Props, Clone, PartialEq)]
164pub struct ResizablePanelProps {
165    /// Default size as percentage (0-100)
166    #[props(default)]
167    pub default_size: Option<f32>,
168    /// Minimum size as percentage
169    #[props(default)]
170    pub min_size: Option<f32>,
171    /// Maximum size as percentage
172    #[props(default)]
173    pub max_size: Option<f32>,
174    /// Panel content
175    pub children: Element,
176}
177
178/// Individual resizable panel component
179#[component]
180pub fn ResizablePanel(props: ResizablePanelProps) -> Element {
181    let mut ctx = use_context::<ResizableContext>();
182    let mut panel_index = use_signal(|| None::<usize>);
183
184    use_hook({
185        let config = PanelConfig {
186            default_size: props.default_size,
187            min_size: props.min_size,
188            max_size: props.max_size,
189        };
190        move || {
191            let idx = *ctx.panel_count.read();
192            panel_index.set(Some(idx));
193            ctx.panel_count.set(idx + 1);
194
195            ctx.panel_configs.with_mut(|configs| {
196                configs.push(config);
197            });
198
199            let new_sizes = calculate_sizes(&ctx.panel_configs.read());
200            ctx.panel_sizes.set(new_sizes);
201        }
202    });
203
204    let size = use_memo(move || {
205        let sizes = ctx.panel_sizes.read();
206        let idx = panel_index.read().unwrap_or(0);
207        sizes.get(idx).copied().unwrap_or(100.0)
208    });
209
210    let is_dragging = use_memo(move || *ctx.dragging.read());
211    let direction = use_memo(move || *ctx.direction.read());
212
213    rsx! {
214        div {
215            class: "resizable-panel",
216            style: format!(
217                "overflow:auto;{};{};{}",
218                match *direction.read() {
219                    Direction::Horizontal => format!("width:{}%", size.read()),
220                    Direction::Vertical => "width:100%".to_string(),
221                },
222                match *direction.read() {
223                    Direction::Horizontal => "height:100%",
224                    Direction::Vertical => &format!("height:{}%", size.read()),
225                },
226                if *is_dragging.read() { "pointer-events:none;" } else { "" }
227            ),
228            {props.children}
229        }
230    }
231}
232
233/// Resizable handle properties
234#[derive(Props, Clone, PartialEq)]
235pub struct ResizableHandleProps {
236    /// Whether the handle is disabled
237    #[props(default = false)]
238    pub disabled: bool,
239}
240
241/// Draggable resize handle component
242#[component]
243pub fn ResizableHandle(props: ResizableHandleProps) -> Element {
244    let theme = use_theme();
245    let mut ctx = use_context::<ResizableContext>();
246    let mut handle_index = use_signal(|| None::<usize>);
247    let mut is_hovered = use_signal(|| false);
248
249    use_hook({
250        let ctx = ctx.clone();
251        move || {
252            let current_count = *ctx.panel_count.read();
253            if current_count > 0 {
254                handle_index.set(Some(current_count - 1));
255            }
256        }
257    });
258
259    let is_dragging = use_memo(move || {
260        let active = *ctx.active_handle.read();
261        let my_idx = *handle_index.read();
262        *ctx.dragging.read() && active == my_idx
263    });
264
265    let direction = *ctx.direction.read();
266    let disabled = props.disabled;
267
268    let handle_style = use_memo({
269        let theme = theme.clone();
270        move || {
271            let t = theme.tokens.read();
272
273            let base = Style::new()
274                .flex_shrink(0)
275                .cursor(if disabled {
276                    "not-allowed"
277                } else {
278                    direction.cursor()
279                })
280                .transition("background-color 150ms ease")
281                .flex()
282                .items_center()
283                .justify_center()
284                .z_index(10);
285
286            let size_style = match direction {
287                Direction::Horizontal => base.w_px(8).h_full(),
288                Direction::Vertical => base.h_px(8).w_full(),
289            };
290
291            let bg_color = if disabled {
292                "transparent".to_string()
293            } else if *is_dragging.read() {
294                t.colors.primary.to_rgba()
295            } else if *is_hovered.read() {
296                t.colors.border.to_rgba()
297            } else {
298                "transparent".to_string()
299            };
300
301            size_style.bg_hex(&bg_color).build()
302        }
303    });
304
305    let indicator_style = use_memo({
306        let theme = theme.clone();
307        move || {
308            let t = theme.tokens.read();
309            let hovered = *is_hovered.read();
310            let is_drag = *is_dragging.read();
311
312            let base = Style::new()
313                .rounded(&t.radius, "full")
314                .transition("all 150ms ease");
315
316            let styled = if is_drag {
317                base.bg(&t.colors.primary).shadow(&t.shadows.md)
318            } else if hovered {
319                base.bg(&t.colors.primary)
320            } else {
321                base.bg(&t.colors.border)
322            };
323
324            match direction {
325                Direction::Horizontal => {
326                    if hovered || is_drag {
327                        styled.w_px(4).h_px(48).build()
328                    } else {
329                        styled.w_px(2).h_px(32).build()
330                    }
331                }
332                Direction::Vertical => {
333                    if hovered || is_drag {
334                        styled.h_px(4).w_px(48).build()
335                    } else {
336                        styled.h_px(2).w_px(32).build()
337                    }
338                }
339            }
340        }
341    });
342
343    let mut panel_sizes = ctx.panel_sizes;
344    let update_sizes_on_drag = {
345        let ctx = ctx.clone();
346        move |current_pos: f32| {
347            let Some(handle_idx) = *ctx.active_handle.read() else {
348                return;
349            };
350
351            let configs = ctx.panel_configs.read();
352            let start_sizes = ctx.drag_start_sizes.read();
353
354            if start_sizes.len() < 2 || handle_idx >= start_sizes.len() - 1 {
355                return;
356            }
357
358            let left_idx = handle_idx;
359            let right_idx = handle_idx + 1;
360
361            let left_size = start_sizes[left_idx];
362            let right_size = start_sizes[right_idx];
363            let start_pos_val = *ctx.drag_start_pos.read();
364
365            let delta_pct = ((current_pos - start_pos_val) / 500.0) * 100.0;
366
367            let left_min = configs[left_idx].min_size.unwrap_or(5.0);
368            let left_max = configs[left_idx].max_size.unwrap_or(95.0);
369            let right_min = configs[right_idx].min_size.unwrap_or(5.0);
370            let right_max = configs[right_idx].max_size.unwrap_or(95.0);
371
372            let new_left = (left_size + delta_pct).clamp(left_min, left_max);
373            let actual_delta = new_left - left_size;
374            let new_right = (right_size - actual_delta).clamp(right_min, right_max);
375
376            let mut new_sizes = (*start_sizes).clone();
377            new_sizes[left_idx] = new_left;
378            new_sizes[right_idx] = new_right;
379
380            panel_sizes.set(new_sizes);
381        }
382    };
383
384    rsx! {
385        div {
386            class: "resizable-handle",
387            style: "{handle_style} {indicator_style}",
388            onmousedown: move |evt: MouseEvent| {
389                if disabled {
390                    return;
391                }
392
393                if let Some(idx) = *handle_index.read() {
394                    let coords = evt.data().client_coordinates();
395                    let pos = match direction {
396                        Direction::Horizontal => coords.x as f32,
397                        Direction::Vertical => coords.y as f32,
398                    };
399                    ctx.active_handle.set(Some(idx));
400                    ctx.dragging.set(true);
401                    ctx.drag_start_pos.set(pos);
402                    ctx.drag_start_sizes.set(ctx.panel_sizes.read().clone());
403                }
404            },
405            onmousemove: {
406                let mut update = update_sizes_on_drag.clone();
407                move |evt: MouseEvent| {
408                    if !*ctx.dragging.read() {
409                        return;
410                    }
411
412                    let coords = evt.data().client_coordinates();
413                    let pos = match direction {
414                        Direction::Horizontal => coords.x as f32,
415                        Direction::Vertical => coords.y as f32,
416                    };
417                    update(pos);
418                }
419            },
420            onmouseup: move |_| {
421                ctx.dragging.set(false);
422                ctx.active_handle.set(None);
423            },
424            onmouseenter: move |_| if !disabled { is_hovered.set(true) },
425            onmouseleave: move |_| {
426                is_hovered.set(false);
427                ctx.dragging.set(false);
428                ctx.active_handle.set(None);
429            },
430            ontouchstart: move |evt: TouchEvent| {
431                if disabled {
432                    return;
433                }
434
435                if let Some(touch) = evt.touches().first() {
436                    if let Some(idx) = *handle_index.read() {
437                        let coords = touch.client_coordinates();
438                        let pos = match direction {
439                            Direction::Horizontal => coords.x as f32,
440                            Direction::Vertical => coords.y as f32,
441                        };
442                        ctx.active_handle.set(Some(idx));
443                        ctx.dragging.set(true);
444                        ctx.drag_start_pos.set(pos);
445                        ctx.drag_start_sizes.set(ctx.panel_sizes.read().clone());
446                    }
447                }
448            },
449            ontouchmove: {
450                let mut update = update_sizes_on_drag.clone();
451                move |evt: TouchEvent| {
452                    if !*ctx.dragging.read() {
453                        return;
454                    }
455
456                    if let Some(touch) = evt.touches().first() {
457                        let coords = touch.client_coordinates();
458                        let pos = match direction {
459                            Direction::Horizontal => coords.x as f32,
460                            Direction::Vertical => coords.y as f32,
461                        };
462                        update(pos);
463                    }
464                }
465            },
466            ontouchend: move |_| {
467                ctx.dragging.set(false);
468                ctx.active_handle.set(None);
469            },
470            ontouchcancel: move |_| {
471                ctx.dragging.set(false);
472                ctx.active_handle.set(None);
473            },
474        }
475    }
476}
477
478/// Hook to access resizable panel sizes
479pub fn use_resizable_panel_sizes() -> Signal<Vec<f32>> {
480    let ctx = use_context::<ResizableContext>();
481    ctx.panel_sizes
482}
483
484/// Hook to set a specific panel size programmatically
485pub fn use_set_resizable_panel_size() -> impl FnMut(usize, f32) {
486    let ctx = use_context::<ResizableContext>();
487    let mut panel_sizes = ctx.panel_sizes;
488    let panel_configs = ctx.panel_configs;
489    move |index: usize, size: f32| {
490        let mut sizes = panel_sizes.write();
491        if index < sizes.len() {
492            let configs = panel_configs.read();
493            let min = configs[index].min_size.unwrap_or(0.0);
494            let max = configs[index].max_size.unwrap_or(100.0);
495            sizes[index] = size.clamp(min, max);
496        }
497    }
498}