Interaction · Pointer: handles inside the box, drags from the press

Resizable's grip sits inside the box's lower right corner and its edge
handles lie inside the edges, their lines on the edge; the edges use
layout::resize_edge, a resize_handle whose line shows only while
hovered, since the box draws its own border. A drag now measures from
the handle's press, kept in keyed state, so the edge follows the
pointer from the first move. Tests drag the right edge downward and
the bottom edge sideways, turn the dial without Shift, and draw a band
with Shift over a selection.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 15:58:19 -04:00
1 parent 040fc94f49
commit c19c45ca8a
6 files changed
+125 -32

No files matched your search

+1
View File
@@ -414,3 +414,4 @@ Per-component lines live in `tasks/`. Tags there:
| T37a | 2 | PASS | |
| T37b | 1 | FAIL | the help panel's rows were bare Tab stops, not a list the keyboard walks; a long support address drew past 280px |
| T37b | 2 | PASS | |
| T38a | 1 | FAIL | the resize grip and edge handles hung half outside the box; the edges' sides, a free turn and a band with Shift untested |
+37 -24
View File
@@ -1,14 +1,14 @@
use std::{cell::Cell, rc::Rc};
use std::rc::Rc;
use gpui::{
AnyElement, App, AppContext, Axis, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId,
InteractiveElement, IntoElement, ParentElement, Pixels, Point, RenderOnce, Size,
StatefulInteractiveElement, Styled, Window, div, point, size,
InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
RenderOnce, Size, StatefulInteractiveElement, Styled, Window, div, point, size,
};
use smallvec::SmallVec;
use crate::{
layout::{resize_handle, seeded::use_seeded},
layout::{resize_edge, seeded::use_seeded},
primitives::FocusRing,
theme::{ActiveTheme, Radius},
};
@@ -25,7 +25,6 @@ struct ResizeDrag {
owner: EntityId,
side: Side,
start: Size<Pixels>,
anchor: Rc<Cell<Option<Point<Pixels>>>>,
}
/// The size after moving `side` by `delta` from `start`, kept within `min` and `max`.
@@ -113,6 +112,8 @@ impl RenderOnce for Resizable {
.unwrap_or_else(|| panic!("resizable {id:?} has no on_resize"));
let limits = self.limits;
let held = use_seeded((id.clone(), "size"), self.size, window, cx);
let pressed =
window.use_keyed_state((id.clone(), "press"), cx, |_, _| None::<Point<Pixels>>);
let (owner, now) = (held.entity_id(), held.read(cx).value);
let report: OnResize = Rc::new(move |next, window, cx| {
log::info!("resizable: {:?} by {:?}", next.width, next.height);
@@ -123,17 +124,26 @@ impl RenderOnce for Resizable {
on_resize(next, window, cx);
});
let theme = cx.theme();
let (hit, grip) = (theme.handle_hit(), theme.interaction().grip);
let grip = theme.interaction().grip;
let step = theme.interaction().step.to_pixels(window.rem_size());
let drag = move |side| ResizeDrag {
owner,
side,
start: now,
anchor: Rc::new(Cell::new(None)),
let grab = |side| {
let drag = ResizeDrag {
owner,
side,
start: now,
};
let at = pressed.clone();
let press = move |event: &MouseDownEvent, _: &mut Window, cx: &mut App| {
at.update(cx, |at, _| *at = Some(event.position))
};
(drag, press)
};
let ((right, right_press), (bottom, bottom_press), (corner, corner_press)) =
(grab(Side::Right), grab(Side::Bottom), grab(Side::Corner));
let (dragged, keyed) = (report.clone(), report);
div()
.id(id.clone())
.debug_selector(|| "resizable-box".into())
.relative()
.w(now.width)
.h(now.height)
@@ -143,10 +153,7 @@ impl RenderOnce for Resizable {
return;
}
let at = event.event.position;
let anchor = drag.anchor.get().unwrap_or_else(|| {
drag.anchor.set(Some(at));
at
});
let anchor = pressed.read(cx).expect("a resize starts from a press");
let next = resized(drag.start, drag.side, at - anchor, limits);
if next != now {
dragged(next, window, cx);
@@ -154,25 +161,30 @@ impl RenderOnce for Resizable {
})
.children(self.children)
.child(
resize_handle((id.clone(), "right"), Axis::Horizontal, cx)
resize_edge((id.clone(), "right"), Axis::Horizontal, cx)
.absolute()
.top_0()
.right(hit * -0.5)
.on_drag(drag(Side::Right), |_, _, _, cx| cx.new(|_| EmptyView)),
.right_0()
.justify_end()
.on_mouse_down(MouseButton::Left, right_press)
.on_drag(right, |_, _, _, cx| cx.new(|_| EmptyView)),
)
.child(
resize_handle((id.clone(), "bottom"), Axis::Vertical, cx)
resize_edge((id.clone(), "bottom"), Axis::Vertical, cx)
.absolute()
.left_0()
.bottom(hit * -0.5)
.on_drag(drag(Side::Bottom), |_, _, _, cx| cx.new(|_| EmptyView)),
.bottom_0()
.items_end()
.on_mouse_down(MouseButton::Left, bottom_press)
.on_drag(bottom, |_, _, _, cx| cx.new(|_| EmptyView)),
)
.child(
div()
.id((id, "corner"))
.debug_selector(|| "resizable-grip".into())
.absolute()
.right(grip * -0.5)
.bottom(grip * -0.5)
.right_0()
.bottom_0()
.size(grip)
.rounded(theme.radius(Radius::Sm))
.border_1()
@@ -195,7 +207,8 @@ impl RenderOnce for Resizable {
keyed(next, window, cx);
}
})
.on_drag(drag(Side::Corner), |_, _, _, cx| cx.new(|_| EmptyView)),
.on_mouse_down(MouseButton::Left, corner_press)
.on_drag(corner, |_, _, _, cx| cx.new(|_| EmptyView)),
)
}
}
+8
View File
@@ -174,6 +174,14 @@ mod tests {
assert_eq!(angle_at(ring, point(px(100.0), px(50.0)), false), 90.0);
assert_eq!(angle_at(ring, point(px(50.0), px(100.0)), false), 180.0);
assert_eq!(angle_at(ring, point(px(0.0), px(50.0)), false), 270.0);
let eleven = point(
px(50.0 + 50.0 * 11f32.to_radians().sin()),
px(50.0 - 50.0 * 11f32.to_radians().cos()),
);
assert!(
(angle_at(ring, eleven, false) - 11.0).abs() < 1e-3,
"free, it stays at 11°"
);
let near = angle_at(ring, point(px(60.0), px(0.0)), true);
assert_eq!(near, 15.0, "11° snaps to the nearest notch");
}
+67 -5
View File
@@ -109,16 +109,43 @@ fn a_box_resizes_from_its_grip_and_its_edge_within_its_limits(cx: &mut TestAppCo
let stepped = host.read_with(cx, |bench, _| bench.size);
assert_eq!(stepped, size(px(208.0), px(128.0)));
drag(
at(228.0, 60.0),
&[at(240.0, 60.0), at(260.0, 60.0), at(400.0, 60.0)],
at(225.0, 60.0),
&[at(240.0, 80.0), at(260.0, 100.0), at(400.0, 120.0)],
Modifiers::none(),
cx,
);
let dragged = host.read_with(cx, |bench, _| bench.size);
let widened = host.read_with(cx, |bench, _| bench.size);
assert_eq!(
dragged,
widened,
size(px(300.0), px(128.0)),
"the edge stops at the widest"
"the right edge only widens, and stops at the widest"
);
drag(
at(100.0, 145.0),
&[at(150.0, 160.0), at(180.0, 180.0), at(200.0, 200.0)],
Modifiers::none(),
cx,
);
let deepened = host.read_with(cx, |bench, _| bench.size);
assert_eq!(
deepened,
size(px(300.0), px(183.0)),
"the bottom edge only deepens, by the pointer's way from the press"
);
}
/// The grip and the edges' handles lie inside the box, the grip in its lower right corner.
#[gpui::test]
fn the_grip_lies_inside_the_box(cx: &mut TestAppContext) {
let (_, cx) = bench(resizable, cx);
let (grip, frame) = (
cx.debug_bounds("resizable-grip").expect("the grip"),
cx.debug_bounds("resizable-box").expect("the box"),
);
assert_eq!(
(grip.right(), grip.bottom()),
(frame.right(), frame.bottom()),
"the grip ends at the box's corner: {grip:?} in {frame:?}"
);
}
@@ -158,6 +185,23 @@ fn a_dial_turns_toward_its_dragged_knob(cx: &mut TestAppContext) {
assert_eq!(host.read_with(cx, |bench, _| bench.angle), 90.0);
}
/// Without Shift the same drag turns the dial to the pointer's own angle.
#[gpui::test]
fn a_free_drag_turns_to_the_pointer(cx: &mut TestAppContext) {
let (host, cx) = bench(rotatable, cx);
drag(
at(70.0, 26.0),
&[at(90.0, 40.0), at(110.0, 55.0), at(130.0, 66.0)],
Modifiers::none(),
cx,
);
let angle = host.read_with(cx, |bench, _| bench.angle);
assert!(
(angle - 86.19).abs() < 0.05,
"turned to 86 degrees: {angle}"
);
}
fn area(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
["a", "b", "c"]
.into_iter()
@@ -222,6 +266,24 @@ fn presses_and_keys_pick_tiles(cx: &mut TestAppContext) {
assert!(picked(&host, cx).is_empty());
}
/// With "a" selected, a band over "c" with Shift adds it.
#[gpui::test]
fn a_band_with_shift_adds_to_the_selection(cx: &mut TestAppContext) {
let (host, cx) = bench(area, cx);
host.update(cx, |bench, cx| {
bench.selected = vec!["a".into()];
cx.notify();
});
settle(cx);
drag(
at(200.0, 100.0),
&[at(190.0, 90.0), at(170.0, 70.0), at(160.0, 60.0)],
Modifiers::shift(),
cx,
);
assert_eq!(picked(&host, cx), ["a", "c"]);
}
/// The box is one stop: the arrows walk the cursor and Space adds or drops the tile it is on.
#[gpui::test]
fn the_keyboard_walks_and_picks_tiles(cx: &mut TestAppContext) {
+1 -1
View File
@@ -27,7 +27,7 @@ pub(crate) use scroll::{bring_into_view, reveal_when_focused};
pub use scroll_aids::{ScrollShadow, ScrollToTop, StickyHeader};
pub use sheet::{Drawer, Sheet};
pub use sidebar::Sidebar;
pub use split::{SplitPane, resize_handle};
pub use split::{SplitPane, resize_edge, resize_handle};
pub use stack::{AspectRatio, Container, ZStack, h_stack, v_stack};
pub use surface::{Card, CardHeader, Fieldset, Frame, Panel, Section, Well};
pub use viewport::{Transform, Viewport};
+11 -2
View File
@@ -1,7 +1,7 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
AnyElement, App, Axis, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId,
AnyElement, App, Axis, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId, Hsla,
InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, Stateful,
StatefulInteractiveElement, Styled, Window, div, prelude::*,
};
@@ -11,8 +11,17 @@ use crate::theme::ActiveTheme;
/// Thin divider with a wide grab area. `axis` is the split's direction.
pub fn resize_handle(id: impl Into<ElementId>, axis: Axis, cx: &App) -> Stateful<gpui::Div> {
handle(id, axis, cx.theme().colors.border, cx)
}
/// A resize handle whose line shows only while hovered, on an edge whose box draws its own border.
pub fn resize_edge(id: impl Into<ElementId>, axis: Axis, cx: &App) -> Stateful<gpui::Div> {
handle(id, axis, gpui::transparent_black(), cx)
}
fn handle(id: impl Into<ElementId>, axis: Axis, line: Hsla, cx: &App) -> Stateful<gpui::Div> {
let theme = cx.theme();
let (line, lit, hit) = (theme.colors.border, theme.colors.focus, theme.handle_hit());
let (lit, hit) = (theme.colors.focus, theme.handle_hit());
let base = div()
.id(id)
.group("resize")