add layout crate with resize handle
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "gpui-native-layout"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Application layout bootstrap: sidebars and a center dock, one cargo feature each"
|
||||
|
||||
[dependencies]
|
||||
gpui = { workspace = true }
|
||||
gpui-native-theme = { workspace = true }
|
||||
gpui-native-utils = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[features]
|
||||
default = ["full"]
|
||||
full = ["resize_handle"]
|
||||
resize_handle = []
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Axis, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _,
|
||||
Pixels, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
use gpui_native_layout::resize_handle::ResizeHandle;
|
||||
use gpui_native_theme::Theme;
|
||||
|
||||
type DeltaSink = Rc<RefCell<Vec<Pixels>>>;
|
||||
type ResetSink = Rc<RefCell<usize>>;
|
||||
|
||||
struct Harness {
|
||||
deltas: Rc<RefCell<Vec<Pixels>>>,
|
||||
resets: Rc<RefCell<usize>>,
|
||||
axis: Axis,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
impl Render for Harness {
|
||||
fn render(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut gpui::Context<Self>,
|
||||
) -> impl gpui::IntoElement {
|
||||
let deltas = self.deltas.clone();
|
||||
let resets = self.resets.clone();
|
||||
div()
|
||||
.size_full()
|
||||
.flex()
|
||||
.flex_row()
|
||||
.child(div().w(px(120.)).h_full())
|
||||
.child(
|
||||
ResizeHandle::new("test-handle")
|
||||
.axis(self.axis)
|
||||
.disabled(self.disabled)
|
||||
.on_resize(move |delta, _, _| deltas.borrow_mut().push(delta))
|
||||
.on_reset(move |_, _| *resets.borrow_mut() += 1),
|
||||
)
|
||||
.child(div().flex_1().h_full())
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_window(
|
||||
axis: Axis,
|
||||
disabled: bool,
|
||||
) -> (TestAppContext, DeltaSink, ResetSink, VisualTestContext) {
|
||||
let mut cx = TestAppContext::single();
|
||||
cx.update(Theme::init);
|
||||
let deltas = Rc::new(RefCell::new(Vec::new()));
|
||||
let resets = Rc::new(RefCell::new(0usize));
|
||||
let deltas_for_window = deltas.clone();
|
||||
let resets_for_window = resets.clone();
|
||||
let window = cx.add_window(move |_, _| Harness {
|
||||
deltas: deltas_for_window,
|
||||
resets: resets_for_window,
|
||||
axis,
|
||||
disabled,
|
||||
});
|
||||
let mut visual = VisualTestContext::from_window(window.into(), &cx);
|
||||
visual.update(|window, cx| {
|
||||
window.draw(cx).clear(cx);
|
||||
});
|
||||
(cx, deltas, resets, visual)
|
||||
}
|
||||
|
||||
fn handle_center(visual: &mut VisualTestContext) -> gpui::Point<gpui::Pixels> {
|
||||
let bounds = visual
|
||||
.debug_bounds("resize-handle")
|
||||
.expect("resize handle missing");
|
||||
point(bounds.center().x, bounds.center().y)
|
||||
}
|
||||
|
||||
fn click(visual: &mut VisualTestContext, position: gpui::Point<gpui::Pixels>) {
|
||||
visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default());
|
||||
visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_reports_signed_deltas() {
|
||||
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false);
|
||||
let start = handle_center(&mut visual);
|
||||
|
||||
visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: point(start.x + px(30.), start.y),
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::default(),
|
||||
});
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: point(start.x + px(10.), start.y),
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::default(),
|
||||
});
|
||||
visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default());
|
||||
|
||||
assert_eq!(*deltas.borrow(), vec![px(30.), px(-20.)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_seams_report_y_deltas() {
|
||||
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Vertical, false);
|
||||
let start = handle_center(&mut visual);
|
||||
|
||||
visual.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: point(start.x, start.y + px(40.)),
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::default(),
|
||||
});
|
||||
visual.simulate_mouse_up(start, MouseButton::Left, Modifiers::default());
|
||||
|
||||
assert_eq!(*deltas.borrow(), vec![px(40.)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_without_a_drag_report_nothing() {
|
||||
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false);
|
||||
let start = handle_center(&mut visual);
|
||||
|
||||
// Hovering and moving across the seam without pressing does nothing.
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: point(start.x + px(30.), start.y),
|
||||
pressed_button: None,
|
||||
modifiers: Modifiers::default(),
|
||||
});
|
||||
// A press-and-move that started elsewhere must not reach the handle either.
|
||||
visual.simulate_mouse_down(
|
||||
point(px(20.), px(20.)),
|
||||
MouseButton::Left,
|
||||
Modifiers::default(),
|
||||
);
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: point(px(50.), px(20.)),
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::default(),
|
||||
});
|
||||
|
||||
assert!(deltas.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_the_seam_when_focused() {
|
||||
let (_cx, deltas, _resets, mut visual) = handle_window(Axis::Horizontal, false);
|
||||
let center = handle_center(&mut visual);
|
||||
click(&mut visual, center);
|
||||
|
||||
visual.simulate_keystrokes("left");
|
||||
visual.simulate_keystrokes("right");
|
||||
assert_eq!(*deltas.borrow(), vec![px(-32.), px(32.)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_resets_and_starts_no_drag() {
|
||||
let (_cx, deltas, resets, mut visual) = handle_window(Axis::Horizontal, false);
|
||||
let center = handle_center(&mut visual);
|
||||
|
||||
for click_count in [1usize, 2] {
|
||||
visual.simulate_event(MouseDownEvent {
|
||||
position: center,
|
||||
click_count,
|
||||
modifiers: Modifiers::default(),
|
||||
button: MouseButton::Left,
|
||||
first_mouse: false,
|
||||
});
|
||||
visual.simulate_event(MouseUpEvent {
|
||||
position: center,
|
||||
click_count,
|
||||
modifiers: Modifiers::default(),
|
||||
button: MouseButton::Left,
|
||||
});
|
||||
}
|
||||
assert_eq!(*resets.borrow(), 1);
|
||||
|
||||
// The double click must not leave a drag behind.
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: point(center.x + px(20.), center.y),
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::default(),
|
||||
});
|
||||
assert!(deltas.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_handle_ignores_drag_and_keys() {
|
||||
let (_cx, deltas, resets, mut visual) = handle_window(Axis::Horizontal, true);
|
||||
let center = handle_center(&mut visual);
|
||||
|
||||
visual.simulate_mouse_down(center, MouseButton::Left, Modifiers::default());
|
||||
visual.simulate_event(MouseMoveEvent {
|
||||
position: point(center.x + px(30.), center.y),
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::default(),
|
||||
});
|
||||
visual.simulate_mouse_up(center, MouseButton::Left, Modifiers::default());
|
||||
visual.simulate_keystrokes("left");
|
||||
|
||||
assert!(deltas.borrow().is_empty());
|
||||
assert_eq!(*resets.borrow(), 0);
|
||||
}
|
||||
Reference in New Issue
Block a user