From 265d2d129bf4382aaee531e2aeb54aafc76488d3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 6 Nov 2026 20:13:08 +0700 Subject: [PATCH] feat: add resizable component --- crates/layout/Cargo.toml | 4 +- crates/layout/src/lib.rs | 3 + crates/layout/src/resizable.rs | 318 +++++++++++++++++++++++++++++++ crates/layout/tests/resizable.rs | 165 ++++++++++++++++ docs/CHECKLIST.md | 4 +- examples/gallery/src/main.rs | 72 ++++++- 6 files changed, 562 insertions(+), 4 deletions(-) create mode 100644 crates/layout/src/resizable.rs create mode 100644 crates/layout/tests/resizable.rs diff --git a/crates/layout/Cargo.toml b/crates/layout/Cargo.toml index 5dc0bc3..490dcb9 100644 --- a/crates/layout/Cargo.toml +++ b/crates/layout/Cargo.toml @@ -17,9 +17,10 @@ gpui = { workspace = true, features = ["test-support"] } [features] default = ["full"] -full = ["dock", "resize_handle", "shell", "window_controls"] +full = ["dock", "resize_handle", "resizable", "shell", "window_controls"] dock = [ "resize_handle", + "resizable", "dep:gpui-native-core", "dep:gpui-native-data", "dep:gpui-native-icons", @@ -28,6 +29,7 @@ dock = [ "window_controls", ] resize_handle = [] +resizable = ["resize_handle"] shell = [ "resize_handle", "window_controls", diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index c531b38..fbbf9e2 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -6,6 +6,9 @@ pub mod dock; #[cfg(feature = "resize_handle")] pub mod resize_handle; +#[cfg(feature = "resizable")] +pub mod resizable; + #[cfg(feature = "shell")] pub mod shell; diff --git a/crates/layout/src/resizable.rs b/crates/layout/src/resizable.rs new file mode 100644 index 0000000..a9dd15f --- /dev/null +++ b/crates/layout/src/resizable.rs @@ -0,0 +1,318 @@ +//! The resizable group: stacked panes with drag dividers, outside the dock. + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Axis, Bounds, Context, ElementId, Entity, + InteractiveElement as _, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, + StyleRefinement, Styled, Window, canvas, div, px, +}; +use gpui_native_theme::{ActiveTheme as _, Size}; +use gpui_native_utils::StyledExt as _; + +use crate::resize_handle::ResizeHandle; + +/// The size a pane starts at before the group has measured its extent. +const DEFAULT_PANE_SIZE: f32 = 200.0; +/// The floor a pane keeps while dragging unless the caller lowers it. +const DEFAULT_PANE_MIN: f32 = 48.0; + +type DebugSelector = std::rc::Rc String>; + +/// The retained pane sizes of one resizable group. +pub struct ResizableState { + /// Sizes of every pane except the remainder pane, which fills what's left. + sizes: Vec, + /// The group's main-axis extent, captured at paint for drag clamping. + measured: Option, +} + +impl ResizableState { + /// Creates state whose panes start at the default size. + pub fn new(cx: &mut App) -> Entity { + cx.new(|_| Self { + sizes: Vec::new(), + measured: None, + }) + } + + /// The stored size of each fixed pane; the remainder pane isn't listed. + pub fn sizes(&self) -> &[Pixels] { + &self.sizes + } + + /// Restores pane sizes, e.g. from a persisted layout. + pub fn set_sizes(&mut self, sizes: I, cx: &mut Context) + where + I: IntoIterator, + { + self.sizes = sizes.into_iter().collect(); + cx.notify(); + } + + /// Records the group's measured extent, notifying only when it changes. + fn set_measured(&mut self, extent: Pixels, cx: &mut Context) { + if self.measured != Some(extent) { + self.measured = Some(extent); + cx.notify(); + } + } + + /// The equal split of the measured extent. + fn equal_split(&self, pane_count: usize, thickness: Pixels) -> Pixels { + match self.measured { + Some(extent) => (extent - thickness * (pane_count as f32 - 1.0)) / pane_count as f32, + None => px(DEFAULT_PANE_SIZE), + } + } + + /// The size pane `ix` renders at: its stored size, or the equal split. + fn pane_size(&self, ix: usize, pane_count: usize, thickness: Pixels) -> Pixels { + self.sizes + .get(ix) + .copied() + .unwrap_or_else(|| self.equal_split(pane_count, thickness)) + } + + /// Applies a drag at divider `ix` (between pane ix and ix+1). + fn resize( + &mut self, + ix: usize, + delta: Pixels, + mins: &[Pixels], + thickness: Pixels, + cx: &mut Context, + ) { + let Some(&floor) = mins.get(ix) else { + return; + }; + + let pane_count = mins.len(); + + let mut size = self.pane_size(ix, pane_count, thickness) + delta; + size = size.max(floor); + + if let Some(extent) = self.measured { + let floors = mins + .iter() + .enumerate() + .filter(|(j, _)| *j != ix) + .fold(px(0.), |sum, (_, min)| sum + *min); + let ceiling = extent - thickness * (pane_count as f32 - 1.0) - floors; + size = size.min(ceiling); + } + + while self.sizes.len() <= ix { + self.sizes.push(px(DEFAULT_PANE_SIZE)); + } + + self.sizes[ix] = size; + cx.notify(); + } + + /// Restores pane `ix` to the equal split, as a double-click does. + fn reset_pane( + &mut self, + ix: usize, + pane_count: usize, + thickness: Pixels, + cx: &mut Context, + ) { + let split = self.equal_split(pane_count, thickness); + while self.sizes.len() <= ix { + self.sizes.push(px(DEFAULT_PANE_SIZE)); + } + self.sizes[ix] = split; + cx.notify(); + } +} + +/// One pane of a resizable group: its content and its drag floor. +pub struct ResizablePane { + content: AnyElement, + min: Pixels, +} + +impl ResizablePane { + /// The floor a pane keeps while dragging unless the caller lowers it. + pub const DEFAULT_MIN: Pixels = px(DEFAULT_PANE_MIN); + + /// Wraps this element as a pane. + pub fn new(content: E) -> Self + where + E: IntoElement, + { + Self { + content: content.into_any_element(), + min: Self::DEFAULT_MIN, + } + } + + /// Sets the floor this pane keeps while dragging. + pub fn min(mut self, min: Pixels) -> Self { + self.min = min; + self + } +} + +/// A group of panes stacked along one axis, separated by drag dividers. +#[derive(IntoElement)] +pub struct Resizable { + id: ElementId, + state: Entity, + axis: Axis, + panes: Vec, + debug_selector: Option, + style: StyleRefinement, +} + +impl Resizable { + /// Creates a group stacking its panes vertically, bound to `state`. + pub fn new(id: I, state: Entity) -> Self + where + I: Into, + { + Self { + id: id.into(), + state, + axis: Axis::Vertical, + panes: Vec::new(), + debug_selector: None, + style: StyleRefinement::default(), + } + } + + /// Sets the axis the panes stack along. + pub fn axis(mut self, axis: Axis) -> Self { + self.axis = axis; + self + } + + /// Adds a pane with the default drag floor. + pub fn pane(mut self, content: E) -> Self + where + E: IntoElement, + { + self.panes.push(ResizablePane::new(content)); + self + } + + /// Adds a pane that can't be dragged below `min`. + pub fn pane_with_min(mut self, content: E, min: Pixels) -> Self + where + E: IntoElement, + { + self.panes.push(ResizablePane::new(content).min(min)); + self + } + + /// Sets the debug-bounds selector of each pane, suffixed with its index. + pub fn debug_selector(mut self, selector: F) -> Self + where + F: Fn() -> String + 'static, + { + self.debug_selector = Some(std::rc::Rc::new(selector)); + self + } +} + +impl Styled for Resizable { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Resizable { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let thickness = cx.theme().sizes.resolve(Size::default()).dense_padding_x; + let axis = self.axis; + let state = self.state.clone(); + + let pane_count = self.panes.len(); + let mins: Vec = self.panes.iter().map(|pane| pane.min).collect(); + let debug_selector = self.debug_selector; + + let sizes: Vec = (0..pane_count) + .map(|ix| state.read(cx).pane_size(ix, pane_count, thickness)) + .collect(); + + let mut group = div() + .flex() + .overflow_hidden() + .when(axis == Axis::Vertical, |group| group.flex_col()) + .when(axis == Axis::Horizontal, |group| group.flex_row()) + .refine_style(&self.style); + + for (ix, pane) in self.panes.into_iter().enumerate() { + let remainder = ix + 1 == pane_count; + let content = pane.content; + let mut pane_element = if remainder { + div() + .flex_1() + .overflow_hidden() + .when(axis == Axis::Vertical, |p| p.min_h(pane.min)) + .when(axis == Axis::Horizontal, |p| p.min_w(pane.min)) + } else { + div() + .flex_none() + .overflow_hidden() + .when(axis == Axis::Vertical, |p| p.h(sizes[ix]).w_full()) + .when(axis == Axis::Horizontal, |p| p.w(sizes[ix]).h_full()) + }; + + if let Some(selector) = &debug_selector { + let prefix = selector(); + pane_element = pane_element.debug_selector(move || format!("{prefix}-{ix}")); + } + + group = group.child(pane_element.child(content)); + + if !remainder { + let divider = + ResizeHandle::new(SharedString::from(format!("{}-divider-{ix}", self.id))) + .axis(axis) + .on_resize({ + let state = state.clone(); + let mins = mins.clone(); + move |delta, _, cx| { + state.update(cx, |state, cx| { + state.resize(ix, delta, &mins, thickness, cx) + }); + } + }) + .on_reset({ + let state = state.clone(); + let mins = mins.clone(); + move |_, cx| { + state.update(cx, |state, cx| { + state.reset_pane(ix, mins.len(), thickness, cx) + }); + } + }); + + group = group.child(divider); + } + } + + if pane_count > 0 { + group = group.child( + canvas( + { + let state = state.clone(); + move |bounds: Bounds, _, cx: &mut App| { + let extent = match axis { + Axis::Vertical => bounds.size.height, + Axis::Horizontal => bounds.size.width, + }; + state.update(cx, |state, cx| state.set_measured(extent, cx)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ); + } + + group + } +} diff --git a/crates/layout/tests/resizable.rs b/crates/layout/tests/resizable.rs new file mode 100644 index 0000000..aee1461 --- /dev/null +++ b/crates/layout/tests/resizable.rs @@ -0,0 +1,165 @@ +use gpui::{ + AppContext as _, Context, Entity, IntoElement, Modifiers, MouseButton, MouseDownEvent, + MouseMoveEvent, ParentElement as _, Pixels, Render, Styled as _, Subscription, TestAppContext, + VisualTestContext, Window, div, point, px, +}; +use gpui_native_layout::resizable::{Resizable, ResizablePane, ResizableState}; +use gpui_native_theme::{ActiveTheme as _, Size, Theme}; + +const GROUP_WIDTH: f32 = 200.0; +const GROUP_HEIGHT: f32 = 300.0; + +struct Harness { + state: Entity, + _observation: Subscription, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().w(px(GROUP_WIDTH)).h(px(GROUP_HEIGHT)).child( + Resizable::new("test-split", self.state.clone()) + .debug_selector(|| "test-pane".to_string()) + .size_full() + .pane(div()) + .pane(div()), + ) + } +} + +fn split_window(sizes: [Pixels; 1]) -> (TestAppContext, Entity, VisualTestContext) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let state = cx.update(ResizableState::new); + state.update(&mut cx, |state, cx| state.set_sizes(sizes, cx)); + let window_state = state.clone(); + let window = cx.add_window(move |_, cx: &mut Context| { + let observation = cx.observe(&window_state, |_, _, cx| cx.notify()); + Harness { + state: window_state, + _observation: observation, + } + }); + let window = window.into(); + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + let visual = VisualTestContext::from_window(window, &cx); + (cx, state, visual) +} + +fn divider_center(visual: &mut VisualTestContext) -> gpui::Point { + let bounds = visual + .debug_bounds("resize-handle") + .expect("divider rendered"); + point(bounds.center().x, bounds.center().y) +} + +fn drag_divider(visual: &mut VisualTestContext, delta: gpui::Pixels) { + let start = divider_center(visual); + visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + visual.simulate_event(MouseMoveEvent { + position: point(start.x, start.y + delta), + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + }); + visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); +} + +#[test] +fn a_drag_moves_the_pane_and_its_rendered_height() { + let (mut cx, state, mut visual) = split_window([px(100.)]); + drag_divider(&mut visual, px(60.)); + + state.update(&mut cx, |state, _| assert_eq!(state.sizes(), &[px(160.)])); + + // The pane re-renders at the new size after the state notifies the owner. + visual.update(|window, cx| window.draw(cx).clear(cx)); + let bounds = visual + .debug_bounds("test-pane-0") + .expect("first pane rendered"); + assert_eq!(bounds.size.height, px(160.)); +} + +#[test] +fn a_drag_stops_where_the_remainder_keeps_its_floor() { + let (mut cx, state, mut visual) = split_window([px(100.)]); + let thickness = cx.update(|cx| cx.theme().sizes.resolve(Size::default()).dense_padding_x); + let floor = ResizablePane::DEFAULT_MIN; + + drag_divider(&mut visual, px(1000.)); + + state.update(&mut cx, |state, _| { + assert_eq!(state.sizes(), &[px(GROUP_HEIGHT) - thickness - floor]); + }); +} + +#[test] +fn a_double_click_resets_the_pane_to_the_equal_split() { + let (mut cx, state, mut visual) = split_window([px(240.)]); + let thickness = cx.update(|cx| cx.theme().sizes.resolve(Size::default()).dense_padding_x); + let start = divider_center(&mut visual); + + visual.simulate_event(MouseDownEvent { + position: start, + modifiers: Modifiers::default(), + button: MouseButton::Left, + click_count: 2, + first_mouse: false, + }); + + state.update(&mut cx, |state, _| { + assert_eq!( + state.sizes(), + &[px((GROUP_HEIGHT - thickness.as_f32()) / 2.)] + ); + }); +} + +#[test] +fn arrow_keys_resize_the_focused_pane() { + let (mut cx, state, mut visual) = split_window([px(100.)]); + let start = divider_center(&mut visual); + + // A single click focuses the handle without resetting anything. + visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default()); + visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default()); + visual.simulate_keystrokes("down"); + visual.simulate_keystrokes("up"); + + state.update(&mut cx, |state, _| assert_eq!(state.sizes(), &[px(100.)])); +} + +#[test] +fn an_unfocused_handle_ignores_arrow_keys() { + let (mut cx, state, mut visual) = split_window([px(100.)]); + let start = divider_center(&mut visual); + let _ = start; + + visual.simulate_keystrokes("down"); + + state.update(&mut cx, |state, _| assert_eq!(state.sizes(), &[px(100.)])); +} + +#[test] +fn the_panes_and_the_divider_fill_the_group_exactly() { + let (_cx, _state, mut visual) = split_window([px(100.)]); + visual.update(|window, cx| window.draw(cx).clear(cx)); + let first = visual + .debug_bounds("test-pane-0") + .expect("first pane rendered"); + let second = visual + .debug_bounds("test-pane-1") + .expect("remainder pane rendered"); + let divider = visual + .debug_bounds("resize-handle") + .expect("divider rendered"); + assert_eq!( + first.size.height + divider.size.height + second.size.height, + px(GROUP_HEIGHT) + ); + assert_eq!( + second.size.height, + px(GROUP_HEIGHT) - first.size.height - divider.size.height + ); +} diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 5ed7755..62dfe07 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -19,8 +19,6 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. - [ ] **Editable code field** — syntax-highlighted editable multiline field, the code view's highlighter over the input's editing model; signed's repo file browser. -- [ ] **Resizable group** — stacked arbitrary panes with drag dividers - outside the dock; signed's commit-diff split. - [ ] **Searchable list filter** — searchable collection behind combobox and list option sources; the combobox's pluggable filter covers part of it (signed's `SearchableVec`). @@ -112,6 +110,8 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. - [x] Titlebar + window controls — app-owned window dragging, per-platform buttons, `Theme::titlebar_height` - [x] Sidebar auto-open — `PanelDescriptor::right_sidebar` + `DockSidebar` +- [x] Resizable group — stacked panes with drag dividers outside the dock, + per-pane floors, double-click reset, keyboard steps ### gallery (`examples/gallery`) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index cc273f4..4ced94e 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -2,7 +2,7 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, Bounds, Context, Entity, FontWeight, InteractiveElement as _, + App, AppContext as _, Axis, Bounds, Context, Entity, FontWeight, InteractiveElement as _, IntoElement, KeyBinding, ListAlignment, ListState, ParentElement as _, Pixels, Render, SharedString, Styled as _, UniformListScrollHandle, WeakEntity, Window, WindowBounds, WindowOptions, actions, div, list, px, size, uniform_list, @@ -48,6 +48,7 @@ use gpui_native_editor::highlight::Language; use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState}; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection}; +use gpui_native_layout::resizable::{Resizable, ResizableState}; use gpui_native_layout::resize_handle::ResizeHandle; use gpui_native_layout::shell::{LayoutState, Shell}; use gpui_native_theme::{ @@ -119,6 +120,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("welcome", "Welcome"), ("sidebar", "Sidebar"), ("resize-handle", "Resize handle"), + ("resizable", "Resizable"), ("separator", "Separator"), ], ), @@ -675,6 +677,12 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("resizable", "Resizable", |_, cx| { + cx.new(ResizableDemo::new).into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("separator", "Separator", |_, cx| { cx.new(|_| SeparatorDemo).into() @@ -1706,6 +1714,68 @@ impl Render for SeparatorDemo { } } +struct ResizableDemo { + vertical: Entity, + horizontal: Entity, + _observations: (gpui::Subscription, gpui::Subscription), +} + +impl ResizableDemo { + fn new(cx: &mut Context) -> Self { + // The panel is a cached dock view: it re-renders only when the size + // entities it reads are observed and notified. Each group owns one. + let vertical = ResizableState::new(cx); + let horizontal = ResizableState::new(cx); + let observations = ( + cx.observe(&vertical, |_, _, cx| cx.notify()), + cx.observe(&horizontal, |_, _, cx| cx.notify()), + ); + Self { + vertical, + horizontal, + _observations: observations, + } + } +} + +impl Render for ResizableDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + let pane = |text: &'static str| { + div().size_full().bg(colors.muted).p_2().child( + div() + .text_sm() + .text_color(colors.muted_foreground) + .child(text), + ) + }; + + ScrollArea::new("resizable-demo") + .size_full() + .p_4() + .child(demo_section( + "vertical", + colors, + Resizable::new("resizable-demo-vertical", self.vertical.clone()) + .size_full() + .h(px(320.)) + .pane_with_min(pane("Tree"), px(96.)) + .pane(pane("Editor")) + .pane(pane("Terminal")), + )) + .child(demo_section( + "horizontal", + colors, + Resizable::new("resizable-demo-horizontal", self.horizontal.clone()) + .axis(Axis::Horizontal) + .size_full() + .h(px(120.)) + .pane(pane("Primary")) + .pane(pane("Inspector")), + )) + } +} + struct BadgeDemo; impl Render for BadgeDemo {