dioxus_ui_system/organisms/
resizable.rs1use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Clone, Copy, PartialEq, Default)]
12pub enum Direction {
13 #[default]
15 Horizontal,
16 Vertical,
18}
19
20impl Direction {
21 fn cursor(self) -> &'static str {
23 match self {
24 Direction::Horizontal => "col-resize",
25 Direction::Vertical => "row-resize",
26 }
27 }
28}
29
30#[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#[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
76fn 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#[derive(Props, Clone, PartialEq)]
117pub struct ResizablePanelGroupProps {
118 #[props(default)]
120 pub direction: Direction,
121 pub children: Element,
123 #[props(default)]
125 pub class: Option<String>,
126}
127
128#[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#[derive(Props, Clone, PartialEq)]
164pub struct ResizablePanelProps {
165 #[props(default)]
167 pub default_size: Option<f32>,
168 #[props(default)]
170 pub min_size: Option<f32>,
171 #[props(default)]
173 pub max_size: Option<f32>,
174 pub children: Element,
176}
177
178#[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#[derive(Props, Clone, PartialEq)]
235pub struct ResizableHandleProps {
236 #[props(default = false)]
238 pub disabled: bool,
239}
240
241#[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
478pub fn use_resizable_panel_sizes() -> Signal<Vec<f32>> {
480 let ctx = use_context::<ResizableContext>();
481 ctx.panel_sizes
482}
483
484pub 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}