Interaction · Pointer: resize, turn, band selection, and gpui's own drag

Resizable sizes a box from its right and bottom edges and a grip at
its corner, within its least and greatest size; the grip is a Tab stop
whose arrows resize a step. Rotatable turns an svg to its angle inside
a ring whose knob a drag moves, Shift snapping to 15 degrees, with the
arrows turning it a notch. SelectionArea selects items with a band
drawn on empty space, a press, Command or Shift to add, and a cursor
the arrows walk with Space to add or drop; Escape clears and
Command-A takes all. The gallery shows gpui's own drag and drop, drag
preview, double press and wheel zoom, with pointers for Sortable,
LongPress and PanZoom; pinch stays blocked, gpui 0.2.2 forwards no
magnify event.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 15:42:03 -04:00
1 parent 8ade21b7ba
commit 040fc94f49
14 files changed
+1413 -16

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
use std::rc::Rc;
use gpui::{
App, AppContext, Bounds, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement,
IntoElement, ParentElement, Pixels, Point, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Transformation, Window, div, radians, relative, svg,
};
use crate::{layout::seeded::use_seeded, primitives::FocusRing, theme::ActiveTheme};
/// What the arrows and Shift snap to, in degrees.
const NOTCH: f32 = 15.0;
struct Turn {
owner: EntityId,
}
/// The angle, clockwise from straight up and in 0..360, at which `at` lies from the middle of `bounds`; `snap` rounds it to a notch.
fn angle_at(bounds: Bounds<Pixels>, at: Point<Pixels>, snap: bool) -> f32 {
let middle = bounds.center();
let (dx, dy) = (f32::from(at.x - middle.x), f32::from(at.y - middle.y));
let angle = dx.atan2(-dy).to_degrees().rem_euclid(360.0);
match snap {
true => ((angle / NOTCH).round() * NOTCH).rem_euclid(360.0),
false => angle,
}
}
type OnTurn = Rc<dyn Fn(f32, &mut Window, &mut App)>;
/// An svg turned to its angle, in degrees clockwise from straight up, inside a ring with a knob at that angle. A drag on the knob turns it, Shift snapping to 15°; with focus, Left and Right turn it by 15°. gpui 0.2.2 turns svgs alone, so the picture is an svg path.
#[derive(IntoElement)]
pub struct Rotatable {
id: ElementId,
path: SharedString,
side: Pixels,
angle: f32,
on_turn: Option<OnTurn>,
}
impl Rotatable {
/// `path` names the svg asset; `side` is the ring's width.
pub fn new(
id: impl Into<ElementId>,
path: impl Into<SharedString>,
side: Pixels,
angle: f32,
) -> Self {
assert!(
angle.is_finite(),
"rotatable: angle {angle} is not a number"
);
Self {
id: id.into(),
path: path.into(),
side,
angle: angle.rem_euclid(360.0),
on_turn: None,
}
}
/// Runs with each new angle.
pub fn on_turn(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
self.on_turn = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Rotatable {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_turn = self
.on_turn
.unwrap_or_else(|| panic!("rotatable {id:?} has no on_turn"));
let held = use_seeded((id.clone(), "angle"), self.angle, window, cx);
let (owner, angle) = (held.entity_id(), held.read(cx).value);
let turn: OnTurn = Rc::new(move |next, window, cx| {
log::info!("rotatable: {next:.0}°");
held.update(cx, |held, cx| {
held.value = next;
cx.notify();
});
on_turn(next, window, cx);
});
let theme = cx.theme();
let (side, knob) = (
self.side,
theme.interaction().grip.to_pixels(window.rem_size()),
);
let reach = (side - knob) / 2.0;
let (sin, cos) = angle.to_radians().sin_cos();
let (dragged, keyed) = (turn.clone(), turn);
div()
.id(id.clone())
.relative()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(side)
.rounded_full()
.border_1()
.border_color(theme.colors.border)
.tab_index(0)
.focus_ring(cx)
.on_drag_move(move |event: &DragMoveEvent<Turn>, window, cx| {
if event.drag(cx).owner != owner {
return;
}
let next = angle_at(
event.bounds,
event.event.position,
event.event.modifiers.shift,
);
if next != angle {
dragged(next, window, cx);
}
})
.on_key_down(move |event, window, cx| {
let by = match event.keystroke.key.as_str() {
"left" => -NOTCH,
"right" => NOTCH,
_ => return,
};
cx.stop_propagation();
keyed(
((angle / NOTCH).round() * NOTCH + by).rem_euclid(360.0),
window,
cx,
);
})
.child(
svg()
.path(self.path)
.size(side * 0.5)
.text_color(theme.colors.fg)
.with_transformation(Transformation::rotate(radians(angle.to_radians()))),
)
.child(
div()
.absolute()
.left(relative(0.5))
.top(relative(0.5))
.ml(reach * sin)
.mt(-(reach * cos))
.size_0()
.flex()
.items_center()
.justify_center()
.child(
div()
.id((id, "knob"))
.flex_none()
.size(knob)
.rounded_full()
.bg(theme.colors.accent)
.cursor(gpui::CursorStyle::OpenHand)
.on_drag(Turn { owner }, |_, _, _, cx| cx.new(|_| EmptyView)),
),
)
}
}
#[cfg(test)]
mod tests {
use gpui::{Bounds, point, px, size};
use super::angle_at;
#[test]
fn an_angle_runs_clockwise_from_straight_up_and_snaps_to_a_notch() {
let ring = Bounds::new(point(px(0.0), px(0.0)), size(px(100.0), px(100.0)));
assert_eq!(angle_at(ring, point(px(50.0), px(0.0)), false), 0.0);
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 near = angle_at(ring, point(px(60.0), px(0.0)), true);
assert_eq!(near, 15.0, "11° snaps to the nearest notch");
}
}