From 10673cc0bade0fb9525c37a24c86fdf8e5d77bcf Mon Sep 17 00:00:00 2001 From: Marc Espin Date: Sat, 21 Dec 2024 11:15:48 +0100 Subject: [PATCH] feat: `ResizableContainer` (#752) * feat: `ResizableContainer` * clean up * theming + use it in the devtools * my head hurts * clean up * fix test * feat: Improvements * feat: Improvements * chore: adapt tests * feat: flex support * move flex to content * flex * feat: min_size * update tests and docs * fix: Update resizing of panels in the opposite resizing direction * flex tests * min size of 75 for the app root * perf improvements * improvement --- crates/components/src/lib.rs | 2 + crates/components/src/resizable_container.rs | 466 +++++++++++++++++++ crates/devtools/src/lib.rs | 31 +- crates/hooks/src/theming/base.rs | 4 + crates/hooks/src/theming/mod.rs | 10 + examples/resizable_container.rs | 122 +++++ 6 files changed, 620 insertions(+), 15 deletions(-) create mode 100644 crates/components/src/resizable_container.rs create mode 100644 examples/resizable_container.rs diff --git a/crates/components/src/lib.rs b/crates/components/src/lib.rs index 7d895175c..8b640600b 100644 --- a/crates/components/src/lib.rs +++ b/crates/components/src/lib.rs @@ -28,6 +28,7 @@ mod overflowed_content; mod popup; mod progress_bar; mod radio; +mod resizable_container; mod scroll_views; mod sidebar; mod slider; @@ -68,6 +69,7 @@ pub use overflowed_content::*; pub use popup::*; pub use progress_bar::*; pub use radio::*; +pub use resizable_container::*; pub use scroll_views::*; pub use sidebar::*; pub use slider::*; diff --git a/crates/components/src/resizable_container.rs b/crates/components/src/resizable_container.rs new file mode 100644 index 000000000..204a1d2fb --- /dev/null +++ b/crates/components/src/resizable_container.rs @@ -0,0 +1,466 @@ +use dioxus::prelude::*; +use freya_common::NodeReferenceLayout; +use freya_elements::{ + elements as dioxus_elements, + events::MouseEvent, +}; +use freya_hooks::{ + use_applied_theme, + use_node_signal, + use_platform, + ResizableHandleTheme, + ResizableHandleThemeWith, +}; +use winit::window::CursorIcon; + +struct Panel { + pub size: f32, + pub min_size: f32, +} + +enum ResizableItem { + Panel(Panel), + Handle, +} + +impl ResizableItem { + /// Get the [Panel] of the [ResizableItem]. Will panic if called in a [ResizableItem::Handle]. + fn panel(&self) -> &Panel { + match self { + Self::Panel(panel) => panel, + Self::Handle => panic!("Not a Panel"), + } + } + + /// Try to get the mutable [Panel] of the [ResizableItem]. Will return [None] if called in a [ResizableItem::Handle]. + fn try_panel_mut(&mut self) -> Option<&mut Panel> { + match self { + Self::Panel(panel) => Some(panel), + Self::Handle => None, + } + } +} + +#[derive(Default)] +struct ResizableContext { + pub registry: Vec, + pub direction: String, +} + +/// Resizable container, used in combination with [ResizablePanel] and [ResizableHandle]. +/// +/// Example: +/// +/// ```no_run +/// # use freya::prelude::*; +/// fn app() -> Element { +/// rsx!( +/// ResizableContainer { +/// direction: "vertical", +/// ResizablePanel { +/// initial_size: 50.0, +/// label { +/// "Panel 1" +/// } +/// } +/// ResizableHandle { } +/// ResizablePanel { +/// initial_size: 50.0, +/// min_size: 30.0, +/// label { +/// "Panel 2" +/// } +/// } +/// } +/// ) +/// } +/// ``` +#[component] +pub fn ResizableContainer( + /// Direction of the container, `vertical`/`horizontal`. + /// Default to `vertical`. + #[props(default = "vertical".to_string())] + direction: String, + /// Inner children for the [ResizableContainer]. + children: Element, +) -> Element { + let (node_reference, size) = use_node_signal(); + use_context_provider(|| size); + + use_context_provider(|| { + Signal::new(ResizableContext { + direction: direction.clone(), + ..Default::default() + }) + }); + + rsx!( + rect { + reference: node_reference, + direction: "{direction}", + width: "fill", + height: "fill", + content: "flex", + {children} + } + ) +} + +/// Resizable panel to be used in combination with [ResizableContainer] and [ResizableHandle]. +#[component] +pub fn ResizablePanel( + /// Initial size in % for this panel. Default to `10`. + #[props(default = 10.)] + initial_size: f32, // TODO: Automatically assign the remaining space in the last element with unspecified size? + /// Minimum size in % for this panel. Default to `4`. + #[props(default = 4.)] + min_size: f32, + /// Inner children for the [ResizablePanel]. + children: Element, +) -> Element { + let mut registry = use_context::>(); + + let index = use_hook(move || { + registry.write().registry.push(ResizableItem::Panel(Panel { + size: initial_size, + min_size, + })); + registry.peek().registry.len() - 1 + }); + + let registry = registry.read(); + + let Panel { size, .. } = registry.registry[index].panel(); + + let (width, height) = match registry.direction.as_str() { + "horizontal" => (format!("flex({size})"), "fill".to_owned()), + _ => ("fill".to_owned(), format!("flex({size}")), + }; + + rsx!( + rect { + width: "{width}", + height: "{height}", + overflow: "clip", + {children} + } + ) +} + +/// Describes the current status of the Handle. +#[derive(Debug, Default, PartialEq, Clone, Copy)] +pub enum HandleStatus { + /// Default state. + #[default] + Idle, + /// Mouse is hovering the handle. + Hovering, +} + +/// Resizable panel to be used in combination with [ResizableContainer] and [ResizablePanel]. +#[component] +pub fn ResizableHandle( + /// Theme override. + theme: Option, +) -> Element { + let ResizableHandleTheme { + background, + hover_background, + } = use_applied_theme!(&theme, resizable_handle); + let (node_reference, size) = use_node_signal(); + let mut clicking = use_signal(|| false); + let mut status = use_signal(HandleStatus::default); + let mut registry = use_context::>(); + let container_size = use_context::>(); + let platform = use_platform(); + let mut allow_resizing = use_signal(|| false); + + use_memo(move || { + size.read(); + allow_resizing.set(true); + + // Only allow more resizing after the node layout has updated + }); + + use_drop(move || { + if *status.peek() == HandleStatus::Hovering { + platform.set_cursor(CursorIcon::default()); + } + }); + + let index = use_hook(move || { + registry.write().registry.push(ResizableItem::Handle); + registry.peek().registry.len() - 1 + }); + + let cursor = match registry.read().direction.as_str() { + "horizontal" => CursorIcon::ColResize, + _ => CursorIcon::RowResize, + }; + + let onmouseleave = move |_: MouseEvent| { + *status.write() = HandleStatus::Idle; + if !clicking() { + platform.set_cursor(CursorIcon::default()); + } + }; + + let onmouseenter = move |e: MouseEvent| { + e.stop_propagation(); + *status.write() = HandleStatus::Hovering; + platform.set_cursor(cursor); + }; + + let onmousemove = move |e: MouseEvent| { + if clicking() { + if !allow_resizing() { + return; + } + + let coordinates = e.get_screen_coordinates(); + let mut registry = registry.write(); + + let displacement_per: f32 = match registry.direction.as_str() { + "horizontal" => { + let container_width = container_size.read().area.width(); + let displacement = coordinates.x as f32 - size.read().area.min_x(); + 100. / container_width * displacement + } + _ => { + let container_height = container_size.read().area.height(); + let displacement = coordinates.y as f32 - size.read().area.min_y(); + 100. / container_height * displacement + } + }; + + let mut changed_panels = false; + + if displacement_per >= 0. { + // Resizing to the right + + let mut acc_per = 0.0; + + // Resize panels to the right + for next_item in &mut registry.registry[index..].iter_mut() { + if let Some(panel) = next_item.try_panel_mut() { + let old_size = panel.size; + let new_size = (panel.size - displacement_per).clamp(panel.min_size, 100.); + + if panel.size != new_size { + changed_panels = true + } + + panel.size = new_size; + acc_per -= new_size - old_size; + + if old_size > panel.min_size { + break; + } + } + } + + // Resize panels to the left + for prev_item in &mut registry.registry[0..index].iter_mut().rev() { + if let Some(panel) = prev_item.try_panel_mut() { + let new_size = (panel.size + acc_per).clamp(panel.min_size, 100.); + + if panel.size != new_size { + changed_panels = true + } + + panel.size = new_size; + break; + } + } + } else { + // Resizing to the left + + let mut acc_per = 0.0; + + // Resize panels to the left + for prev_item in &mut registry.registry[0..index].iter_mut().rev() { + if let Some(panel) = prev_item.try_panel_mut() { + let old_size = panel.size; + let new_size = (panel.size + displacement_per).clamp(panel.min_size, 100.); + + if panel.size != new_size { + changed_panels = true + } + + panel.size = new_size; + acc_per += new_size - old_size; + + if old_size > panel.min_size { + break; + } + } + } + + // Resize panels to the right + for next_item in &mut registry.registry[index..].iter_mut() { + if let Some(panel) = next_item.try_panel_mut() { + let new_size = (panel.size - acc_per).clamp(panel.min_size, 100.); + + if panel.size != new_size { + changed_panels = true + } + + panel.size = new_size; + break; + } + } + } + + if changed_panels { + allow_resizing.set(false); + } + } + }; + + let onmousedown = move |e: MouseEvent| { + e.stop_propagation(); + clicking.set(true); + }; + + let onclick = move |_: MouseEvent| { + if clicking() { + if *status.peek() != HandleStatus::Hovering { + platform.set_cursor(CursorIcon::default()); + } + clicking.set(false); + } + }; + + let (width, height) = match registry.read().direction.as_str() { + "horizontal" => ("4", "fill"), + _ => ("fill", "4"), + }; + + let background = match status() { + _ if clicking() => hover_background, + HandleStatus::Hovering => hover_background, + HandleStatus::Idle => background, + }; + + rsx!(rect { + reference: node_reference, + width: "{width}", + height: "{height}", + background: "{background}", + onmousedown, + onglobalclick: onclick, + onmouseenter, + onglobalmousemove: onmousemove, + onmouseleave, + }) +} + +#[cfg(test)] +mod test { + use freya::prelude::*; + use freya_testing::prelude::*; + + #[tokio::test] + pub async fn resizable_container() { + fn resizable_container_app() -> Element { + rsx!( + ResizableContainer { + ResizablePanel { + initial_size: 50., + label { + "Panel 0" + } + } + ResizableHandle { } + ResizablePanel { // Panel 1 + initial_size: 50., + ResizableContainer { + direction: "horizontal", + ResizablePanel { + initial_size: 33.33, + label { + "Panel 2" + } + } + ResizableHandle { } + ResizablePanel { + initial_size: 33.33, + label { + "Panel 3" + } + } + ResizableHandle { } + ResizablePanel { + initial_size: 33.33, + label { + "Panel 4" + } + } + } + } + } + ) + } + + let mut utils = launch_test(resizable_container_app); + utils.wait_for_update().await; + let root = utils.root(); + + let container = root.get(0); + let panel_0 = container.get(0); + let panel_1 = container.get(2); + let panel_2 = panel_1.get(0).get(0); + let panel_3 = panel_1.get(0).get(2); + let panel_4 = panel_1.get(0).get(4); + + assert_eq!(panel_0.layout().unwrap().area.height().round(), 248.0); + assert_eq!(panel_1.layout().unwrap().area.height().round(), 248.0); + assert_eq!(panel_2.layout().unwrap().area.width().round(), 164.0); + assert_eq!(panel_3.layout().unwrap().area.width().round(), 164.0); + assert_eq!(panel_4.layout().unwrap().area.width().round(), 164.0); + + // Vertical + utils.push_event(PlatformEvent::Mouse { + name: EventName::MouseDown, + cursor: (100.0, 250.0).into(), + button: Some(MouseButton::Left), + }); + utils.push_event(PlatformEvent::Mouse { + name: EventName::MouseMove, + cursor: (100.0, 200.0).into(), + button: Some(MouseButton::Left), + }); + utils.push_event(PlatformEvent::Mouse { + name: EventName::MouseUp, + cursor: (0.0, 0.0).into(), + button: Some(MouseButton::Left), + }); + utils.wait_for_update().await; + + assert_eq!(panel_0.layout().unwrap().area.height().round(), 200.0); // 250 - 50 + assert_eq!(panel_1.layout().unwrap().area.height().round(), 296.0); // 500 - 200 - 4 + + // Horizontal + utils.push_event(PlatformEvent::Mouse { + name: EventName::MouseDown, + cursor: (167.0, 300.0).into(), + button: Some(MouseButton::Left), + }); + utils.push_event(PlatformEvent::Mouse { + name: EventName::MouseMove, + cursor: (187.0, 300.0).into(), + button: Some(MouseButton::Left), + }); + utils.push_event(PlatformEvent::Mouse { + name: EventName::MouseUp, + cursor: (0.0, 0.0).into(), + button: Some(MouseButton::Left), + }); + utils.wait_for_update().await; + utils.wait_for_update().await; + utils.wait_for_update().await; + + assert_eq!(panel_2.layout().unwrap().area.width().round(), 187.0); // 167 + 20 + assert_eq!(panel_3.layout().unwrap().area.width().round(), 141.0); + } +} diff --git a/crates/devtools/src/lib.rs b/crates/devtools/src/lib.rs index f79b7b510..b2806cde2 100644 --- a/crates/devtools/src/lib.rs +++ b/crates/devtools/src/lib.rs @@ -77,24 +77,25 @@ fn AppWithDevtools(props: AppWithDevtoolsProps) -> Element { rsx!( NativeContainer { - rect { - width: "100%", - height: "100%", + ResizableContainer { direction: "horizontal", - rect { - overflow: "clip", - height: "100%", - width: "calc(100% - 350)", + ResizablePanel { + initial_size: 75., Root { }, } - rect { - background: "rgb(40, 40, 40)", - height: "100%", - width: "350", - ThemeProvider { - DevTools { - devtools_receiver, - hovered_node + ResizableHandle { } + ResizablePanel { + initial_size: 25., + min_size: 10., + rect { + background: "rgb(40, 40, 40)", + height: "fill", + width: "fill", + ThemeProvider { + DevTools { + devtools_receiver, + hovered_node + } } } } diff --git a/crates/hooks/src/theming/base.rs b/crates/hooks/src/theming/base.rs index d9ba77a18..b3663e7c4 100644 --- a/crates/hooks/src/theming/base.rs +++ b/crates/hooks/src/theming/base.rs @@ -259,4 +259,8 @@ pub(crate) const BASE_THEME: Theme = Theme { width: cow_borrowed!("auto"), height: cow_borrowed!("auto"), }, + resizable_handle: ResizableHandleTheme { + background: cow_borrowed!("key(secondary_surface)"), + hover_background: cow_borrowed!("key(surface)"), + }, }; diff --git a/crates/hooks/src/theming/mod.rs b/crates/hooks/src/theming/mod.rs index f06c02ae8..e2e463150 100644 --- a/crates/hooks/src/theming/mod.rs +++ b/crates/hooks/src/theming/mod.rs @@ -528,6 +528,15 @@ define_theme! { } } +define_theme! { + %[component] + pub ResizableHandle { + %[cows] + background: str, + hover_background: str, + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ColorsSheet { pub primary: Cow<'static, str>, @@ -614,6 +623,7 @@ pub struct Theme { pub popup: PopupTheme, pub tab: TabTheme, pub bottom_tab: BottomTabTheme, + pub resizable_handle: ResizableHandleTheme, } impl Default for Theme { diff --git a/examples/resizable_container.rs b/examples/resizable_container.rs new file mode 100644 index 000000000..9d4506d25 --- /dev/null +++ b/examples/resizable_container.rs @@ -0,0 +1,122 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] + +use freya::prelude::*; + +fn main() { + launch_with_props(app, "Resizable Container", (1000.0, 550.0)); +} + +fn app() -> Element { + rsx!( + ResizableContainer { + ResizablePanel { + initial_size: 50., + rect { + width: "fill", + height: "fill", + main_align: "center", + cross_align: "center", + label { + "Panel 1" + } + } + } + ResizableHandle { } + ResizablePanel { // Panel 1 + initial_size: 50., + ResizableContainer { + direction: "horizontal", + ResizablePanel { + initial_size: 35., + rect { + width: "fill", + height: "fill", + main_align: "center", + cross_align: "center", + corner_radius: "6", + color: "white", + background: + "linear-gradient(250deg, orange 15%, rgb(255, 0, 0) 50%, rgb(255, 192, 203) 80%)", + label { + "Panel 1" + } + } + } + ResizableHandle { } + ResizablePanel { + initial_size: 20., + min_size: 20., + rect { + width: "fill", + height: "fill", + main_align: "center", + cross_align: "center", + corner_radius: "6", + color: "white", + background: + "linear-gradient(250deg, orange 15%, rgb(255, 0, 0) 50%, rgb(255, 192, 203) 80%)", + label { + "Panel 2" + } + } + } + ResizableHandle { } + ResizablePanel { + initial_size: 20., + rect { + width: "fill", + height: "fill", + main_align: "center", + cross_align: "center", + corner_radius: "6", + color: "white", + background: + "linear-gradient(250deg, orange 15%, rgb(255, 0, 0) 50%, rgb(255, 192, 203) 80%)", + label { + "Panel 3" + } + } + } + ResizableHandle { } + ResizablePanel { + initial_size: 15., + min_size: 10., + rect { + width: "fill", + height: "fill", + main_align: "center", + cross_align: "center", + corner_radius: "6", + color: "white", + background: + "linear-gradient(250deg, orange 15%, rgb(255, 0, 0) 50%, rgb(255, 192, 203) 80%)", + label { + "Panel 4" + } + } + } + ResizableHandle { } + ResizablePanel { + initial_size: 10., + rect { + width: "fill", + height: "fill", + main_align: "center", + cross_align: "center", + corner_radius: "6", + color: "white", + background: + "linear-gradient(250deg, orange 15%, rgb(255, 0, 0) 50%, rgb(255, 192, 203) 80%)", + label { + "Panel 5" + } + } + } + } + } + } + ) +}