add layout crate with resize handle

This commit is contained in:
2026-11-01 20:58:45 +07:00
parent 021b907491
commit 3e03137d1c
11 changed files with 600 additions and 5 deletions
+4
View File
@@ -0,0 +1,4 @@
//! Application layout: sidebars and a center dock bootstrapped for the app (plan §6.4).
#[cfg(feature = "resize_handle")]
pub mod resize_handle;
+278
View File
@@ -0,0 +1,278 @@
//! The drag divider: a thin handle that resizes the adjacent region.
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, Axis, Bounds, CursorStyle, DispatchPhase, ElementId, HitboxBehavior,
InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, RenderOnce, Role,
SharedString, StatefulInteractiveElement as _, Styled as _, Window, canvas, div, fill, point,
px,
};
use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_utils::{focus_handle, focus_ring};
/// How far one keyboard arrow step moves the seam.
const KEYBOARD_STEP: f32 = 32.0;
type ResizeHandler = Rc<dyn Fn(Pixels, &mut Window, &mut App)>;
type ResetHandler = Rc<dyn Fn(&mut Window, &mut App)>;
/// The pointer drag state, keyed to the handle's element id.
#[derive(Clone, Copy)]
struct DragState {
dragging: bool,
last: Point<Pixels>,
}
/// A thin drag divider that resizes the adjacent region.
#[derive(IntoElement)]
pub struct ResizeHandle {
id: ElementId,
/// The axis the drag moves along: a vertical seam drags horizontally.
axis: Axis,
disabled: bool,
a11y_label: Option<SharedString>,
on_resize: Option<ResizeHandler>,
on_reset: Option<ResetHandler>,
}
impl ResizeHandle {
/// Creates a horizontal resize handle (a vertical seam).
pub fn new<I>(id: I) -> Self
where
I: Into<ElementId>,
{
Self {
id: id.into(),
axis: Axis::Horizontal,
disabled: false,
a11y_label: None,
on_resize: None,
on_reset: None,
}
}
/// Sets the axis the drag moves along.
pub fn axis(mut self, axis: Axis) -> Self {
self.axis = axis;
self
}
/// Renders the handle inert and dimmed.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Overrides the accessibility label, which defaults to "Resize".
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.a11y_label = Some(label.into());
self
}
/// Runs for every pointer movement while the seam is dragged.
pub fn on_resize<F>(mut self, handler: F) -> Self
where
F: Fn(Pixels, &mut Window, &mut App) + 'static,
{
self.on_resize = Some(Rc::new(handler));
self
}
/// Runs when the seam is double-clicked, to restore the default size.
pub fn on_reset<F>(mut self, handler: F) -> Self
where
F: Fn(&mut Window, &mut App) + 'static,
{
self.on_reset = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ResizeHandle {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let border = theme.colors.border;
let ring = theme.colors.ring;
let thickness = theme.sizes.resolve(Size::default()).dense_padding_x;
let label = self
.a11y_label
.clone()
.unwrap_or_else(|| SharedString::from("Resize"));
let drag = window.with_element_namespace(self.id.clone(), |window| {
window
.use_keyed_state("resize-drag", cx, |_, _| DragState {
dragging: false,
last: point(px(0.), px(0.)),
})
.clone()
});
let focus = focus_handle(&self.id, window, cx);
let dragging = drag.read(cx).dragging;
let axis = self.axis;
let disabled = self.disabled;
let cursor = match axis {
Axis::Horizontal => CursorStyle::ResizeLeftRight,
Axis::Vertical => CursorStyle::ResizeUpDown,
};
let on_reset = self.on_reset;
div()
.id(self.id.clone())
.debug_selector(|| "resize-handle".to_string())
.role(Role::Splitter)
.aria_label(label)
.flex_none()
.when(axis == Axis::Horizontal, |handle| {
handle.w(thickness).h_full()
})
.when(axis == Axis::Vertical, |handle| {
handle.w_full().h(thickness)
})
.when(self.disabled, |handle| handle.opacity(0.5))
.when(!self.disabled, |handle| {
handle
.track_focus(&focus)
.focusable()
.tab_stop(true)
.focus_visible(|style| style.shadow(focus_ring(ring)))
.on_mouse_down(MouseButton::Left, {
let drag = drag.clone();
let focus = focus.clone();
move |event: &MouseDownEvent, window, cx| {
window.focus(&focus, cx);
cx.stop_propagation();
if event.click_count >= 2 {
drag.update(cx, |drag, cx| {
drag.dragging = false;
cx.notify();
});
if let Some(on_reset) = on_reset.clone() {
on_reset(window, cx);
}
return;
}
drag.update(cx, |drag, cx| {
drag.dragging = true;
drag.last = event.position;
cx.notify();
});
}
})
.on_key_down({
let on_resize = self.on_resize.clone();
move |event: &KeyDownEvent, window, cx| {
let delta = match (axis, event.keystroke.key.as_str()) {
(Axis::Horizontal, "left") => Some(px(-KEYBOARD_STEP)),
(Axis::Horizontal, "right") => Some(px(KEYBOARD_STEP)),
(Axis::Vertical, "up") => Some(px(-KEYBOARD_STEP)),
(Axis::Vertical, "down") => Some(px(KEYBOARD_STEP)),
_ => None,
};
if let Some(delta) = delta {
cx.stop_propagation();
if let Some(on_resize) = on_resize.clone() {
on_resize(delta, window, cx);
}
}
}
})
})
.child(
canvas(
{
move |bounds, window, _| {
let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal);
(hitbox, bounds)
}
},
{
let drag = drag.clone();
let on_resize = self.on_resize.clone();
move |_, (hitbox, bounds), window, _cx| {
let hovered = hitbox.is_hovered(window);
if !disabled {
window.set_cursor_style(cursor, &hitbox);
}
// The hairline highlights while hovered or dragged.
let line_color = if !disabled && (dragging || hovered) {
ring
} else {
border
};
let line = match axis {
Axis::Horizontal => {
let x = bounds.center().x - px(0.5);
Bounds::from_corners(
point(x, bounds.top()),
point(x + px(1.), bounds.bottom()),
)
}
Axis::Vertical => {
let y = bounds.center().y - px(0.5);
Bounds::from_corners(
point(bounds.left(), y),
point(bounds.right(), y + px(1.)),
)
}
};
window.paint_quad(fill(line, line_color));
window.on_mouse_event({
let drag = drag.clone();
let on_resize = on_resize.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble {
return;
}
let (dragging, last) = {
let drag = drag.read(cx);
(drag.dragging, drag.last)
};
if !dragging {
return;
}
let delta = match axis {
Axis::Horizontal => event.position.x - last.x,
Axis::Vertical => event.position.y - last.y,
};
drag.update(cx, |drag, _| drag.last = event.position);
if let Some(on_resize) = on_resize.clone() {
on_resize(delta, window, cx);
}
}
});
window.on_mouse_event({
let drag = drag.clone();
move |event: &MouseUpEvent, phase, _, cx| {
if phase != DispatchPhase::Bubble
|| event.button != MouseButton::Left
{
return;
}
if drag.read(cx).dragging {
drag.update(cx, |drag, cx| {
drag.dragging = false;
cx.notify();
});
}
}
});
}
},
)
.absolute()
.inset_0(),
)
}
}