diff --git a/.theme-schema.json b/.theme-schema.json index 3d6968989f..bd38ddcae4 100644 --- a/.theme-schema.json +++ b/.theme-schema.json @@ -325,6 +325,10 @@ "description": "Progress bar background color.", "type": ["string", "null"] }, + "resizable.border": { + "description": "The hairline a resize handle draws at rest. Falls back to `border`.", + "type": ["string", "null"] + }, "ring": { "description": "Used for focus ring.", "type": ["string", "null"] diff --git a/crates/base/src/lib.rs b/crates/base/src/lib.rs index 1978efc4ab..1e70e94dc7 100644 --- a/crates/base/src/lib.rs +++ b/crates/base/src/lib.rs @@ -149,13 +149,13 @@ pub use progress::{Progress, ProgressIndicator, ProgressTrack}; pub use radio::{Radio, RadioStyles}; pub use radio_group::RadioGroup; pub use reduce_motion::apply_system_reduce_motion; +#[doc(hidden)] +pub use resizable::PANEL_MIN_SIZE; pub use resizable::{ HandleEdge, ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, - ResizeHandleContext, ResizeHandleRenderer, ResizeHandleState, h_resizable, resizable_panel, - v_resizable, + ResizeHandle, ResizeHandleContext, ResizeHandleRenderer, ResizeHandleState, h_resizable, + resizable_panel, resize_handle, v_resizable, }; -#[doc(hidden)] -pub use resizable::{PANEL_MIN_SIZE, resize_handle}; pub use root::{Root, RootPlugin}; pub use scroll_bounce::{ScrollBounce, ScrollBounceMotion}; pub use scrollable_mask::ScrollableMask; diff --git a/crates/base/src/resizable/mod.rs b/crates/base/src/resizable/mod.rs index b536c2d1a8..0c4c887c41 100644 --- a/crates/base/src/resizable/mod.rs +++ b/crates/base/src/resizable/mod.rs @@ -7,8 +7,10 @@ use gpui::{ mod panel; mod resize_handle; pub use panel::*; -#[doc(hidden)] -pub use resize_handle::*; +pub use resize_handle::{ + HandleEdge, ResizeHandle, ResizeHandleContext, ResizeHandleRenderer, ResizeHandleState, + resize_handle, +}; #[doc(hidden)] pub const PANEL_MIN_SIZE: Pixels = px(100.); diff --git a/crates/base/src/resizable/panel.rs b/crates/base/src/resizable/panel.rs index d4032077f7..8cc7ff0347 100644 --- a/crates/base/src/resizable/panel.rs +++ b/crates/base/src/resizable/panel.rs @@ -1,13 +1,10 @@ -use std::{ - ops::{Deref, Range}, - rc::Rc, -}; +use std::{ops::Range, rc::Rc}; use gpui::{ Along, AnyElement, App, AppContext, Axis, Bounds, Context, Element, ElementId, Empty, Entity, EventEmitter, InteractiveElement as _, IntoElement, IsZero as _, MouseMoveEvent, MouseUpEvent, - ParentElement, Pixels, Render, RenderOnce, Style, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder, + ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _, Style, + StyleRefinement, Styled, Window, div, prelude::FluentBuilder, }; use crate::{AxisExt, ElementExt, StyledExt as _, h_flex, resizable::PANEL_MIN_SIZE, v_flex}; @@ -373,7 +370,7 @@ impl RenderOnce for ResizablePanel { state.update(cx, |state, _| { state.resizing_panel_ix = Some(ix); }); - cx.new(|_| drag_panel.deref().clone()) + cx.new(|_| drag_panel.clone()) }), ) }) diff --git a/crates/base/src/resizable/resize_handle.rs b/crates/base/src/resizable/resize_handle.rs index 781c9a3d57..100393917f 100644 --- a/crates/base/src/resizable/resize_handle.rs +++ b/crates/base/src/resizable/resize_handle.rs @@ -1,23 +1,40 @@ use std::{cell::Cell, rc::Rc}; use gpui::{ - AnyElement, App, Axis, Element, ElementId, Entity, GlobalElementId, Hitbox, HitboxBehavior, - InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement as _, Pixels, Point, Render, StatefulInteractiveElement, Styled as _, Window, - div, prelude::FluentBuilder as _, px, + AnyElement, App, Axis, Div, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, + InteractiveElement, Interactivity, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + ParentElement as _, Pixels, StatefulInteractiveElement, Styled as _, Window, div, + prelude::FluentBuilder as _, px, }; -use crate::{AxisExt as _, theme::ActiveTheme as _}; +use crate::{AxisExt as _, InteractiveElementExt, TestSupportExt as _, theme::ActiveTheme as _}; pub(crate) const HANDLE_PADDING: Pixels = px(4.); pub(crate) const HANDLE_SIZE: Pixels = px(1.); -/// Create a resize handle for a resizable panel. -#[doc(hidden)] -pub fn resize_handle( - id: impl Into, - axis: Axis, -) -> ResizeHandle { +/// Create a resize handle: a band that resizes whatever it edges when it is +/// dragged. +/// +/// Base owns the band -- where it sits, how wide it is, its cursor, and the +/// Idle → Hovered → Pressed → Dragging progression its appearance is told +/// about. What a drag resizes is the caller's. Give the handle a payload with +/// [`on_drag`](StatefulInteractiveElement::on_drag) and follow that payload +/// with [`on_drag_move`](InteractiveElement::on_drag_move), the way a slider +/// thumb does; any other listener -- a double click that restores a default +/// size, a hover that a surrounding drawer has to count as its own -- goes on +/// the handle the same way. +/// +/// The band occludes. While the pointer is on it nothing behind it reads as +/// hovered, its own container included, so a container that has to know the +/// pointer is still over it listens on the handle as well. +/// +/// ```ignore +/// resize_handle("sidebar-edge", Axis::Horizontal) +/// .inside(HandleEdge::Trailing) +/// .on_drag(ResizeSidebar, |drag, _, _, cx| cx.new(|_| drag.clone())) +/// .on_double_click(|_, _, cx| restore_default_width(cx)) +/// ``` +pub fn resize_handle(id: impl Into, axis: Axis) -> ResizeHandle { ResizeHandle::new(id, axis) } @@ -124,26 +141,27 @@ pub enum HandleEdge { Trailing, } -#[doc(hidden)] -pub struct ResizeHandle { +/// A band that resizes along one axis when it is dragged. See +/// [`resize_handle`]. +pub struct ResizeHandle { id: ElementId, axis: Axis, - drag_value: Option>, edge: Option, - on_drag: Option, &mut Window, &mut App) -> Entity>>, appearance: Option, + /// The band itself. Listeners a caller attaches through the interactive + /// element traits land here, on the element the pointer actually hits; + /// base places and sizes it when it is laid out. + base: Div, } -impl ResizeHandle { +impl ResizeHandle { fn new(id: impl Into, axis: Axis) -> Self { - let id = id.into(); Self { - id: id.clone(), - on_drag: None, - drag_value: None, + id: id.into(), + axis, edge: None, appearance: None, - axis, + base: div(), } } @@ -153,19 +171,6 @@ impl ResizeHandle { self } - pub fn on_drag( - mut self, - value: T, - f: impl Fn(Rc, &Point, &mut Window, &mut App) -> Entity + 'static, - ) -> Self { - let value = Rc::new(value); - self.drag_value = Some(value.clone()); - self.on_drag = Some(Rc::new(move |p, window, cx| { - f(value.clone(), p, window, cx) - })); - self - } - /// Keep the whole handle inside its container, hugging `edge`, instead of /// straddling the boundary it resizes. pub fn inside(mut self, edge: HandleEdge) -> Self { @@ -174,6 +179,16 @@ impl ResizeHandle { } } +impl InteractiveElement for ResizeHandle { + fn interactivity(&mut self) -> &mut Interactivity { + self.base.interactivity() + } +} + +impl StatefulInteractiveElement for ResizeHandle {} + +impl InteractiveElementExt for ResizeHandle {} + /// One handle's [`ResizeHandleState`], shared between the element and the /// mouse listeners it registers. /// @@ -200,14 +215,14 @@ impl SharedHandleState { } } -impl IntoElement for ResizeHandle { - type Element = ResizeHandle; +impl IntoElement for ResizeHandle { + type Element = ResizeHandle; fn into_element(self) -> Self::Element { self } } -impl Element for ResizeHandle { +impl Element for ResizeHandle { type RequestLayoutState = AnyElement; /// The band's own hitbox, so the listeners in `paint` can ask whether the /// pointer is really on the handle rather than merely within its bounds. @@ -237,24 +252,22 @@ impl Element for ResizeHandle { // push it. let hug_extent = HANDLE_SIZE + HANDLE_PADDING; let straddle_extent = HANDLE_SIZE + HANDLE_PADDING * 2.; + // The caller's listeners are already on it; what base adds is where the + // band goes and what it holds. + let base = std::mem::replace(&mut self.base, div()); window.with_element_state(id.unwrap(), |state, window| { let state: SharedHandleState = state.unwrap_or_default(); let bg_color = handle_color(&cx.theme(), state.get().is_active()); - let mut el = div() + let mut el = base .id(self.id.clone()) + .test_support() .occlude() .absolute() .flex_shrink_0() .group("handle") - .when_some(self.on_drag.clone(), |this, on_drag| { - this.on_drag( - self.drag_value.clone().unwrap(), - move |_, position, window, cx| on_drag(&position, window, cx), - ) - }) .map(|this| match (edge, axis) { // Hugging an edge: the whole band is inside the container, // padded on the inner side only, so the hairline is the @@ -470,11 +483,16 @@ pub(crate) fn handle_color(theme: &crate::Theme, active: bool) -> gpui::Hsla { #[cfg(test)] mod tests { - use std::{cell::Cell, rc::Rc}; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + }; use gpui::{ - AnyElement, App, Axis, Bounds, Context, Empty, IntoElement, ParentElement as _, Pixels, - Render, Styled as _, TestAppContext, Window, deferred, div, hsla, + AnyElement, App, AppContext as _, Axis, Bounds, Context, DragMoveEvent, Empty, + InteractiveElement as _, IntoElement, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, + ParentElement as _, Pixels, Render, StatefulInteractiveElement as _, Styled as _, + TestAppContext, VisualTestContext, Window, deferred, div, hsla, point, prelude::FluentBuilder as _, px, }; @@ -482,7 +500,7 @@ mod tests { HandleEdge, ResizeHandleContext, ResizeHandleState, SharedHandleState, handle_color, resize_handle, }; - use crate::{ElementExt as _, ResizableTheme, Theme}; + use crate::{ElementExt as _, InteractiveElementExt as _, ResizableTheme, Theme}; /// What a hugging handle's renderer was told and drew, and where the /// drawing landed in the frame: under which mask, and before or after a @@ -524,15 +542,6 @@ mod tests { .into_any_element() } - /// A drag payload for a handle nobody drags in these tests. - struct NoDrag; - - impl Render for NoDrag { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - Empty - } - } - /// A dock-shaped box: 200px along the axis, clipped to itself the way /// `dock_frame` is, sitting between two 100px neighbours so both of its /// edges are seams. The handle hugs one of them. @@ -580,7 +589,7 @@ mod tests { Axis::Vertical => dock.h(px(200.)).w_full(), }) .child( - resize_handle::<(), NoDrag>("hugging", axis) + resize_handle("hugging", axis) .inside(self.edge) .with_appearance(Rc::new( move |handle: &ResizeHandleContext, @@ -771,4 +780,159 @@ mod tests { assert_eq!(handle_color(&theme, true), active); }); } + + /// The payload a caller drags a handle with. + #[derive(Clone)] + struct Resize; + + impl Render for Resize { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Empty + } + } + + /// What the listeners a caller put on a handle, and on its container, heard. + #[derive(Default)] + struct Heard { + container_hovered: Cell>, + handle_hovered: Cell>, + double_clicks: Cell, + drag_moves: RefCell>, + /// Every state the appearance was drawn in, repeats collapsed. + states: RefCell>, + } + + /// A 200px container whose trailing edge a handle hugs, listened to the way + /// an application's own sidebar would be. + struct CallerHarness { + heard: Rc, + } + + impl Render for CallerHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let heard = self.heard.clone(); + div().w(px(400.)).h(px(100.)).child( + div() + .id("container") + .relative() + .w(px(200.)) + .h_full() + .on_hover({ + let heard = heard.clone(); + move |hovered: &bool, _, _| heard.container_hovered.set(Some(*hovered)) + }) + .on_drag_move({ + let heard = heard.clone(); + move |event: &DragMoveEvent, _, _| { + heard.drag_moves.borrow_mut().push(event.event.position.x) + } + }) + .child( + resize_handle("edge", Axis::Horizontal) + .inside(HandleEdge::Trailing) + .with_appearance(Rc::new({ + let heard = heard.clone(); + move |handle: &ResizeHandleContext, _: &mut Window, _: &mut App| { + let mut states = heard.states.borrow_mut(); + if states.last() != Some(&handle.state()) { + states.push(handle.state()); + } + None + } + })) + .on_hover({ + let heard = heard.clone(); + move |hovered: &bool, _, _| heard.handle_hovered.set(Some(*hovered)) + }) + .on_double_click({ + let heard = heard.clone(); + move |_, _, _| { + heard.double_clicks.set(heard.double_clicks.get() + 1) + } + }) + .on_drag(Resize, |drag, _, _, cx| cx.new(|_| drag.clone())), + ), + ) + } + } + + /// Listeners a caller attaches to a handle reach the band, and leave the + /// progression base reports to the appearance as it was. + #[gpui::test] + fn a_callers_listeners_reach_the_band(cx: &mut TestAppContext) { + let heard = Rc::new(Heard::default()); + let (_, cx) = cx.add_window_view({ + let heard = heard.clone(); + move |_, _| CallerHarness { heard } + }); + let draw = |cx: &mut VisualTestContext| cx.update(|window, cx| window.draw(cx).clear(cx)); + let press = |cx: &mut VisualTestContext, click_count| { + cx.simulate_event(MouseDownEvent { + position: point(px(198.), px(50.)), + modifiers: Modifiers::default(), + button: MouseButton::Left, + click_count, + first_mouse: false, + }); + }; + let release = |cx: &mut VisualTestContext, x, click_count| { + cx.simulate_event(MouseUpEvent { + position: point(x, px(50.)), + modifiers: Modifiers::default(), + button: MouseButton::Left, + click_count, + }); + }; + draw(cx); + + // The container spans 0..200, and the band hugs its last five pixels. + cx.simulate_mouse_move(point(px(100.), px(50.)), None, Modifiers::default()); + draw(cx); + assert_eq!(heard.container_hovered.get(), Some(true)); + + cx.simulate_mouse_move(point(px(198.), px(50.)), None, Modifiers::default()); + draw(cx); + assert_eq!(heard.handle_hovered.get(), Some(true)); + // The band occludes, so its own container stops reading as hovered. + assert_eq!(heard.container_hovered.get(), Some(false)); + + for click_count in [1, 2] { + press(cx, click_count); + draw(cx); + release(cx, px(198.), click_count); + draw(cx); + } + assert_eq!(heard.double_clicks.get(), 1); + + press(cx, 1); + draw(cx); + for x in [px(190.), px(120.)] { + cx.simulate_mouse_move( + point(x, px(50.)), + Some(MouseButton::Left), + Modifiers::default(), + ); + draw(cx); + } + release(cx, px(120.), 1); + draw(cx); + + assert_eq!(heard.drag_moves.borrow().last(), Some(&px(120.))); + // A drag is no click, let alone a double one. + assert_eq!(heard.double_clicks.get(), 1); + assert_eq!( + *heard.states.borrow(), + vec![ + ResizeHandleState::Idle, + ResizeHandleState::Hovered, + ResizeHandleState::Pressed, + ResizeHandleState::Hovered, + ResizeHandleState::Pressed, + ResizeHandleState::Hovered, + ResizeHandleState::Pressed, + ResizeHandleState::Dragging, + ResizeHandleState::Idle, + ] + ); + } } diff --git a/crates/component/src/dock/dock.rs b/crates/component/src/dock/dock.rs index 47057c3a6b..25e688d63f 100644 --- a/crates/component/src/dock/dock.rs +++ b/crates/component/src/dock/dock.rs @@ -1,12 +1,12 @@ //! The gpui-component appearance for the dock area: the outer frame, the //! split frames, and one dock's chrome. -use std::{ops::Deref as _, rc::Rc, sync::Arc}; +use std::{rc::Rc, sync::Arc}; use gpui::{ AnyElement, App, AppContext as _, Axis, Context, Div, Element, Empty, InteractiveElement as _, - IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, Style, - Styled as _, Window, div, + IntoElement, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, Stateful, + StatefulInteractiveElement as _, Style, Styled as _, Window, div, }; use gpui_base::{ HandleEdge, ResizeHandleContext, @@ -21,8 +21,7 @@ use crate::{ dock::{ DockSkin, SkinShared, invalid_panel::InvalidPanel, panel_handle, tab_panel::TabGroupSkin, }, - resizable::{render_resize_handle, resize_handle_appearance}, - resize_handle, + resizable::{render_resize_handle, resize_handle}, }; /// The payload a dock's resize handle drags. It draws nothing: the handle @@ -139,14 +138,14 @@ impl DockSkin { _ => HandleEdge::Leading, }; - resize_handle(id, placement.axis()) - .with_appearance(resize_handle_appearance()) - .inside(edge) - .on_drag(ResizePanel, move |info, _, _, cx| { + resize_handle(id, placement.axis()).inside(edge).on_drag( + ResizePanel, + move |info, _, _, cx| { cx.stop_propagation(); shared.resizing_dock().set(Some(placement)); - cx.new(|_| info.deref().clone()) - }) + cx.new(|_| info.clone()) + }, + ) } } diff --git a/crates/component/src/lib.rs b/crates/component/src/lib.rs index 3b2f2b2a69..48671d2c8c 100644 --- a/crates/component/src/lib.rs +++ b/crates/component/src/lib.rs @@ -95,8 +95,6 @@ pub use element_ext::*; pub use global_state::GlobalState; pub use gpui_base::Root; pub use gpui_base::animation; -#[doc(hidden)] -pub(crate) use gpui_base::resize_handle; pub use gpui_base::{ AxisExt, Edges, FocusTrapElement, InteractiveElementExt, LengthExt, Measure, OngoingScrollExt, Placement, Side, measure, measure_if, @@ -109,7 +107,7 @@ pub use input::{Rope, RopeExt, RopeLines}; pub use inspector::*; pub use resizable::{ ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, h_resizable, - resizable_panel, resize_handle_appearance, v_resizable, + resizable_panel, resize_handle, resize_handle_appearance, v_resizable, }; pub use styled::*; pub use theme::*; diff --git a/crates/component/src/resizable.rs b/crates/component/src/resizable.rs index c78e5c943a..0d6f75080e 100644 --- a/crates/component/src/resizable.rs +++ b/crates/component/src/resizable.rs @@ -16,7 +16,8 @@ use gpui_base::{ }; pub use gpui_base::{ - ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, resizable_panel, + HandleEdge, ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, + ResizeHandle, resizable_panel, }; use crate::theme::ActiveTheme as _; @@ -34,6 +35,14 @@ pub fn v_resizable(id: impl Into) -> ResizablePanelGroup { gpui_base::v_resizable(id).with_handle_appearance(resize_handle_appearance()) } +/// Create a standalone [`ResizeHandle`] wearing this design system's divider, +/// for an edge no [`ResizablePanelGroup`] owns -- an application's sidebar, +/// say. What the drag resizes is the caller's; see +/// [`gpui_base::resize_handle`]. +pub fn resize_handle(id: impl Into, axis: Axis) -> ResizeHandle { + gpui_base::resize_handle(id, axis).with_appearance(resize_handle_appearance()) +} + /// This design system's divider appearance, for a handle that base does not /// already hand it — a dock edge, or a hand-rolled handle in an application. pub fn resize_handle_appearance() -> ResizeHandleRenderer { @@ -90,7 +99,7 @@ pub(crate) fn render_resize_handle( // nothing to give: shrinking it collapses the divider. .flex_none() .flex() - .bg(cx.theme().border) + .bg(cx.theme().resizable_border) // Along the hairline the pill is far shorter than the line, so centring // it there is safe. Across the hairline it is thicker than the line and // has to overhang, and neither flex alignment can be trusted to centre diff --git a/crates/component/src/theme/mod.rs b/crates/component/src/theme/mod.rs index 7ac341bd52..215d54876f 100644 --- a/crates/component/src/theme/mod.rs +++ b/crates/component/src/theme/mod.rs @@ -449,7 +449,7 @@ impl Theme { }), ), resizable: gpui_base::ResizableTheme { - handle: Some(self.border), + handle: Some(self.resizable_border), active_handle: Some(self.drag_border), }, plot: gpui_base::PlotTheme::new().with_motion(plot_motion(&self.motion)), @@ -1089,7 +1089,7 @@ mod base_theme_projection_tests { base.scrollbar.motion(), scrollbar_motion(theme.scrollbar_mode) ); - assert_eq!(base.resizable.handle, Some(theme.border)); + assert_eq!(base.resizable.handle, Some(theme.resizable_border)); assert_eq!(base.resizable.active_handle, Some(theme.drag_border)); } diff --git a/crates/component/src/theme/schema.rs b/crates/component/src/theme/schema.rs index 1e0edbcd50..69e812bab9 100644 --- a/crates/component/src/theme/schema.rs +++ b/crates/component/src/theme/schema.rs @@ -482,6 +482,9 @@ pub struct ThemeConfigColors { /// Progress bar background color. #[serde(rename = "progress.bar.background")] pub progress_bar: Option, + /// The hairline a resize handle draws at rest. + #[serde(rename = "resizable.border")] + pub resizable_border: Option, /// Used for focus ring. #[serde(rename = "ring")] pub ring: Option, @@ -974,6 +977,7 @@ impl ThemeColor { apply_background_color!(popover, fallback = tokens.background); apply_color!(popover_foreground, fallback = self.foreground); apply_background_color!(progress_bar, fallback = tokens.primary); + apply_color!(resizable_border, fallback = self.border); apply_color!(ring, fallback = self.blue); apply_background_color!(scrollbar, fallback = tokens.background); apply_background_color!(scrollbar_thumb, fallback = tokens.accent); @@ -1226,6 +1230,27 @@ mod tests { assert_eq!(theme.chart_grid, theme.border.opacity(0.6)); } + #[test] + fn test_apply_config_reads_the_resizable_border() { + let config = serde_json::from_value::(serde_json::json!({ + "name": "Palette", + "mode": "light", + "colors": { + "border": "#cccccc", + "resizable.border": "#eeeeee" + } + })) + .unwrap(); + + let mut theme = Theme::default(); + theme.apply_config(&std::rc::Rc::new(config)); + assert_eq!(theme.resizable_border, try_parse_color("#eeeeee").unwrap()); + + // Left unset, a divider keeps drawing in the border color it always had. + theme.apply_config(&std::rc::Rc::new(ThemeConfig::default())); + assert_eq!(theme.resizable_border, theme.border); + } + #[test] fn test_apply_config_preserves_gradient_background_and_solid_color_fallback() { let config = serde_json::from_value::(serde_json::json!({ diff --git a/crates/component/src/theme/theme_color.rs b/crates/component/src/theme/theme_color.rs index 76c0961079..2cfb1a3a7e 100644 --- a/crates/component/src/theme/theme_color.rs +++ b/crates/component/src/theme/theme_color.rs @@ -209,6 +209,8 @@ pub struct ThemeColor { pub primary_hover: Hsla, /// Progress bar background color. pub progress_bar: Hsla, + /// The hairline a resize handle draws at rest. + pub resizable_border: Hsla, /// Used for focus ring. pub ring: Hsla, /// Scrollbar background color. @@ -479,6 +481,7 @@ define_theme_tokens! { primary_foreground, primary_hover, progress_bar, + resizable_border, ring, scrollbar, scrollbar_thumb, diff --git a/crates/story/src/stories/theme_story/mapper.rs b/crates/story/src/stories/theme_story/mapper.rs index 6f97dd7a41..c0b7e9f241 100644 --- a/crates/story/src/stories/theme_story/mapper.rs +++ b/crates/story/src/stories/theme_story/mapper.rs @@ -131,6 +131,9 @@ pub fn parse_theme_key(key: &str) -> ParsedKey { "switch" => ("Switch", "Background", "switch.background"), "switch_thumb" => ("Switch", "Thumb", "switch.thumb.background"), + // Resizable + "resizable_border" => ("Resizable", "Border", "resizable.border"), + // Muted / Skeleton "muted" => ("Muted", "Background", "muted.background"), "muted_foreground" => ("Muted", "Foreground", "muted.foreground"), diff --git a/release-notes.md b/release-notes.md index 7731c83183..fa3496ddcd 100644 --- a/release-notes.md +++ b/release-notes.md @@ -22,6 +22,28 @@ applies to every group. Use it when a single page should present its items directly — `GroupBoxVariant::Normal` removes the card surface the global default draws — while the other pages keep the global variant. +#### Added: standalone `resize_handle` + +```rust +pub fn resize_handle(id: impl Into, axis: Axis) -> ResizeHandle // gpui_base: the band +pub fn resize_handle(id: impl Into, axis: Axis) -> ResizeHandle // gpui_component: with the divider appearance +impl InteractiveElement for ResizeHandle +impl StatefulInteractiveElement for ResizeHandle +impl InteractiveElementExt for ResizeHandle +``` + +The handle `h_resizable` and the Dock put on their dividers can now edge +anything else, such as an application's own sidebar. It keeps the band, the +cursor and the hovered / pressed / dragging indicator; what a drag resizes is +the caller's, through `on_drag` and `on_drag_move`, and any other listener — +`on_hover`, `on_double_click` — goes on the handle the usual way. The builder +had been hidden: it drops its type parameters and its own +`on_drag(value, Fn(Rc, &Point, ..))` in favour of +`StatefulInteractiveElement::on_drag`. + +The divider's hairline is drawn in the new `resizable.border` theme color +(`ThemeColor::resizable_border`), which falls back to `border`. + #### Plot moves to `gpui-base` The chart primitives — scales, shapes, `PlotAxis`, `Grid`, `PlotLabel`, diff --git a/skills/gpui-kit/SKILL.md b/skills/gpui-kit/SKILL.md index 6832cc2ccd..808a56cbbe 100644 --- a/skills/gpui-kit/SKILL.md +++ b/skills/gpui-kit/SKILL.md @@ -199,6 +199,7 @@ fetch the component's `.md` doc. | `Collapsible` | `collapsible::Collapsible` | Single collapsible | | `GroupBox` | `group_box::GroupBox` | Labeled container | | `Resizable` | `resizable::{h_resizable, v_resizable, resizable_panel, ResizableState}` | Draggable split panes | +| `ResizeHandle` | `resizable::{resize_handle, HandleEdge}` | Standalone resize edge | | `Scrollbar` | `scroll::Scrollbar` | Custom scrollbar | ### Data Display diff --git a/website/base/primitives/resizable.md b/website/base/primitives/resizable.md index b213897a29..939f93f456 100644 --- a/website/base/primitives/resizable.md +++ b/website/base/primitives/resizable.md @@ -42,6 +42,10 @@ Base owns a handle's hit band, its cursor and the drag; what is painted inside i Returning `None` keeps base's own one-pixel line, so a renderer can override some handles and leave the rest alone. +## Standalone handle + +`resize_handle(id, axis)` builds the same handle for an edge no panel group owns. It implements `InteractiveElement` and `StatefulInteractiveElement`, so the drag and anything else — `on_drag`, `on_drag_move`, `on_hover`, `on_double_click` — are attached the usual way, and what the drag resizes stays with the caller. `inside(HandleEdge)` keeps the band within its container; without it the band straddles the boundary. + ## Complete Rust example The complete implementation used by the runnable showcase is embedded directly from Rust source: diff --git a/website/component/resizable.md b/website/component/resizable.md index b23497ffef..e3f7ae6f3f 100644 --- a/website/component/resizable.md +++ b/website/component/resizable.md @@ -11,8 +11,8 @@ The resizable component system provides a flexible way to create layouts with re ```rust use gpui_kit::component::resizable::{ - h_resizable, v_resizable, resizable_panel, - ResizablePanelGroup, ResizablePanel, ResizableState, ResizablePanelEvent + h_resizable, v_resizable, resizable_panel, resize_handle, + ResizablePanelGroup, ResizablePanel, ResizableState, ResizablePanelEvent, HandleEdge }; ``` @@ -77,6 +77,37 @@ gpui_kit::base::h_resizable("my-layout") The indicator's duration and easing come from the theme's motion tokens, and a system reduced-motion preference takes it straight to its target. +The hairline is drawn in the theme's `resizable.border` color, which falls back to `border`. + +### Standalone Resize Handle + +An edge that no panel group owns — an application's own sidebar, say — can use `resize_handle` on its own. The handle owns the band, the cursor and the indicator; what a drag resizes is yours. Give it a payload with `on_drag`, follow that payload with `on_drag_move`, and put any other listener on the handle the same way: + +```rust +use gpui_kit::component::resizable::{HandleEdge, resize_handle}; + +div() + .relative() + .w(self.sidebar_width) + .h_full() + .on_drag_move(cx.listener(|this, e: &DragMoveEvent, _, cx| { + this.sidebar_width = e.event.position.x; + cx.notify(); + })) + .child(sidebar_content) + .child( + resize_handle("sidebar-edge", Axis::Horizontal) + .inside(HandleEdge::Trailing) + .on_drag(ResizeSidebar, |drag, _, _, cx| cx.new(|_| drag.clone())) + .on_double_click(cx.listener(|this, _, _, cx| { + this.sidebar_width = DEFAULT_SIDEBAR_WIDTH; + cx.notify(); + })), + ) +``` + +`inside` keeps the whole band within its container, hugging the named edge; without it the band straddles the boundary, half on either side. The band occludes, so while the pointer is on it nothing behind it — its own container included — reads as hovered. A container that needs to know the pointer is still over it listens on the handle too. + ### Panel Size Constraints ```rust diff --git a/website/zh-CN/base/primitives/resizable.md b/website/zh-CN/base/primitives/resizable.md index 1bce5846cf..f832a1d5d6 100644 --- a/website/zh-CN/base/primitives/resizable.md +++ b/website/zh-CN/base/primitives/resizable.md @@ -40,6 +40,10 @@ ResizableState 持久保存面板尺寸;拖动手柄时更新约束内的比 renderer 返回 `None` 时保留 Base 自带的 1px 细线,因此只想改其中几个手柄时,不必把其余的一并重画。 +## 独立手柄 + +`resize_handle(id, axis)` 为不属于任何面板组的边缘创建同一种手柄。它实现了 `InteractiveElement` 和 `StatefulInteractiveElement`,拖拽和其他交互(`on_drag`、`on_drag_move`、`on_hover`、`on_double_click`)都按常规方式挂上去,拖拽改变什么由调用方决定。`inside(HandleEdge)` 让命中带留在容器内;不调用时,命中带骑在边界上。 + ## 完整 Rust 示例 <<< ../../../../crates/base/examples/showcase/components/resizable.rs{rust} diff --git a/website/zh-CN/component/resizable.md b/website/zh-CN/component/resizable.md index 10fdd5b3fe..c81d4cf7f3 100644 --- a/website/zh-CN/component/resizable.md +++ b/website/zh-CN/component/resizable.md @@ -11,8 +11,8 @@ Resizable 组件系统用于构建可拖拽调整大小的面板布局,支持 ```rust use gpui_kit::component::resizable::{ - h_resizable, v_resizable, resizable_panel, - ResizablePanelGroup, ResizablePanel, ResizableState, ResizablePanelEvent + h_resizable, v_resizable, resizable_panel, resize_handle, + ResizablePanelGroup, ResizablePanel, ResizableState, ResizablePanelEvent, HandleEdge }; ``` @@ -73,6 +73,37 @@ gpui_kit::base::h_resizable("my-layout") 指示条的时长与缓动取自主题的 motion token;系统开启了减弱动态效果时,它直接到位,不播过渡。 +细线颜色取自主题的 `resizable.border`,未设置时回退到 `border`。 + +### 独立手柄 + +不属于任何面板组的边缘——比如应用自己的侧栏——可以单独使用 `resize_handle`。手柄负责命中带、光标和指示条;拖拽改变什么由你决定:用 `on_drag` 给它一个拖拽负载,用 `on_drag_move` 跟踪这个负载,其他监听也照样挂在手柄上: + +```rust +use gpui_kit::component::resizable::{HandleEdge, resize_handle}; + +div() + .relative() + .w(self.sidebar_width) + .h_full() + .on_drag_move(cx.listener(|this, e: &DragMoveEvent, _, cx| { + this.sidebar_width = e.event.position.x; + cx.notify(); + })) + .child(sidebar_content) + .child( + resize_handle("sidebar-edge", Axis::Horizontal) + .inside(HandleEdge::Trailing) + .on_drag(ResizeSidebar, |drag, _, _, cx| cx.new(|_| drag.clone())) + .on_double_click(cx.listener(|this, _, _, cx| { + this.sidebar_width = DEFAULT_SIDEBAR_WIDTH; + cx.notify(); + })), + ) +``` + +`inside` 让整条命中带留在容器内,贴住指定的边;不调用时,命中带骑在边界上,两侧各占一半。命中带会遮挡身后的元素:指针停在上面时,它身后的元素(包括所在容器)都不再算作悬停。容器如果需要知道指针还在自己上方,要在手柄上也挂一个监听。 + ### 面板尺寸约束 ```rust