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

+2 -1
View File
@@ -51,7 +51,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- gpui 0.2.2 panics on a deferred draw inside another. Whatever draws over the page goes through `primitives::raise`: at the top it defers; inside something raised it lays out on its own and draws last there, so a list opened in a dialog lies over the dialog. A raise takes its owner's id and draws under it, so its keyed state and focus hold while other raises come and go. `scripts/check.sh` refuses gpui's `deferred` anywhere else.
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
- gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn.
- gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. A field that reports its words as they change keeps what it sent, so the owner's echo leaves the caret alone (`mail::SignatureEditor`). A cursor inside a seeded view stays while it lies in what is shown, so the owner's echo of a turn leaves it (`calendar::CalendarMonthView`, `YearView`).
- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. gpui's mac window stops its display link while occluded, so a capture advances a moving picture only on the draws the harness forces.
- Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one.
+2 -1
View File
@@ -130,7 +130,8 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T36c Account · Billing and team — `tasks/ch31-43.md`
- [x] T37a Onboarding · Flows and highlights — `tasks/ch31-43.md`
- [x] T37b Onboarding · Help — `tasks/ch31-43.md`
- [ ] T38 Interaction
- [ ] T38a Interaction · Pointer
- [ ] T38b Interaction · Keys, scroll and focus
- [ ] T39 Theme
- [ ] T40 i18n & a11y
- [ ] T41 Maps
+35
View File
@@ -0,0 +1,35 @@
use gpui::{AnyElement, App, Window, div, prelude::*};
mod pointer;
use super::Page;
use crate::script::Step;
pub const PAGE: Page = Page {
number: 38,
slug: "interaction",
title: "Interaction",
summary: "Drag and drop, resizing and turning, band selection, presses and gestures.",
render,
script: SCRIPT,
};
const SCRIPT: &[Step] = &[
Step::DownAt("interaction-tiles", 296.0, 6.0),
Step::DragTo("interaction-tiles", 120.0, 120.0),
Step::Wait(100),
Step::Shot("band"),
Step::UpAt("interaction-tiles", 120.0, 120.0),
Step::DownAt("interaction-rotatable", 98.0, 22.0),
Step::DragTo("interaction-rotatable", 116.0, 70.0),
Step::UpAt("interaction-rotatable", 116.0, 70.0),
Step::Wait(100),
Step::Shot("turned"),
];
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
div()
.child(pointer::drag_and_drop(window, cx))
.child(pointer::shapes(window, cx))
.into_any_element()
}
@@ -0,0 +1,325 @@
use ely_gpui_component::{
interaction::{Resizable, Rotatable, SelectionArea},
layout::{Transform, Viewport},
motion::Reorder,
primitives::{FocusRing, Pressable},
theme::{ActiveTheme, Elevation, Radius, TextSize},
};
use gpui::{
App, Context, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window, div, point, prelude::*, px, size,
};
use crate::{
probe::probe,
ui::{blocked, change, code, keep, section, specimen, specimens},
};
/// A chip on its way between lanes: its name and the lane it left.
#[derive(Clone)]
struct Chip {
name: SharedString,
from: usize,
}
/// What follows the pointer while a chip is dragged.
struct ChipPreview(SharedString);
impl Render for ChipPreview {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
div()
.px_3()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border_strong)
.bg(theme.colors.surface)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.child(self.0.clone())
}
}
const LANES: [&str; 2] = ["Today", "Later"];
/// Which lane each chip is in.
struct Board(Vec<(SharedString, usize)>);
pub fn drag_and_drop(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = keep(
"interaction-board",
|| {
Board(
["Design", "Build", "Ship"]
.map(|name| (name.into(), 0))
.to_vec(),
)
},
window,
cx,
);
let chips = state.read(cx).0.clone();
let theme = cx.theme();
let lanes = LANES.iter().enumerate().map(|(lane, title)| {
let dropped = state.clone();
let held = chips.iter().filter(|(_, at)| *at == lane).map(|(name, _)| {
let chip = Chip {
name: name.clone(),
from: lane,
};
div()
.id(SharedString::from(format!("interaction-chip-{name}")))
.px_3()
.py_1p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.surface)
.cursor(gpui::CursorStyle::OpenHand)
.text_size(theme.text_size(TextSize::Sm))
.on_drag(chip, |chip, _, _, cx| {
cx.new(|_| ChipPreview(chip.name.clone()))
})
.child(name.clone())
});
let lit = theme.colors.hover;
div()
.id(SharedString::from(format!("interaction-lane-{lane}")))
.w(px(180.))
.min_h(px(140.))
.flex()
.flex_col()
.gap_2()
.p_3()
.rounded(theme.radius(Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.can_drop(move |value, _, _| {
value
.downcast_ref::<Chip>()
.is_some_and(|chip| chip.from != lane)
})
.drag_over::<Chip>(move |style, _, _, _| style.bg(lit))
.on_drop(move |chip: &Chip, _, cx| {
let name = chip.name.clone();
change(&dropped, cx, |board| {
for (each, at) in board.0.iter_mut() {
if *each == name {
*at = lane;
}
}
})
})
.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.child(*title),
)
.children(held)
});
section(
"Draggable · Droppable / DropTarget · DragOverlay / DragPreview · DragGhost",
"gpui's own drag and drop: on_drag carries a value and builds the view that follows the pointer, drag_over lights a lane, can_drop refuses the lane a chip left, and on_drop takes it. Ely adds nothing.",
cx,
)
.child(div().flex().flex_wrap().gap_4().children(lanes))
.child(code("on_drag(value, |value, offset, window, cx| cx.new(…)) · drag_over::<T>(…) · can_drop(…) · on_drop(…)", cx))
}
/// The pointer demos' state.
struct Shapes {
size: gpui::Size<gpui::Pixels>,
angle: f32,
picked: Vec<SharedString>,
order: Vec<&'static str>,
presses: usize,
doubles: usize,
held: usize,
}
pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let state = keep(
"interaction-shapes",
|| Shapes {
size: size(px(260.), px(140.)),
angle: 45.0,
picked: vec!["tile-2".into()],
order: vec!["Inbox", "Today", "Upcoming", "Someday"],
presses: 0,
doubles: 0,
held: 0,
},
window,
cx,
);
let now = state.read(cx);
let (size_now, angle, picked, order) =
(now.size, now.angle, now.picked.clone(), now.order.clone());
let (presses, doubles, held) = (now.presses, now.doubles, now.held);
let theme = cx.theme();
let [resize, turn, pick, sort, double, press, long] = [(); 7].map(|_| state.clone());
let tiles = (0..12).fold(
SelectionArea::new("interaction-tiles")
.w(px(300.))
.flex()
.flex_wrap()
.gap(px(8.))
.p_3()
.rounded_lg()
.border_1()
.border_color(theme.colors.border),
|area, ix| {
area.item(
SharedString::from(format!("tile-{ix}")),
div().size(px(56.)).rounded_md().bg(theme.colors.sunken),
)
},
);
let list = order
.iter()
.fold(Reorder::new("interaction-sortable"), |list, name| {
list.row(
*name,
div()
.px_3()
.py_2()
.rounded_md()
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.surface)
.child(*name),
)
});
section(
"Resizable · Rotatable · Selectable / SelectionArea · RubberBandSelection · Sortable",
"A box resized from its edges or its grip; a picture turned by its knob, as gpui turns svgs alone; tiles taken by a band drawn on empty space; rows sorted by drag through motion::Reorder.",
cx,
)
.child(
specimens()
.child(specimen(
"Resizable",
probe(
"interaction-resizable",
Resizable::new("interaction-resizable", size_now, size(px(160.), px(96.)), size(px(360.), px(220.)))
.on_resize(move |next, _, cx| change(&resize, cx, |shapes| shapes.size = next))
.child(
div()
.size_full()
.p_4()
.rounded_lg()
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.surface)
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(format!("{:.0} × {:.0}", f32::from(size_now.width), f32::from(size_now.height))),
),
),
cx,
))
.child(specimen(
"Rotatable",
probe(
"interaction-rotatable",
Rotatable::new("interaction-rotatable", "icons/arrow-up.svg", px(120.), angle)
.on_turn(move |next, _, cx| change(&turn, cx, |shapes| shapes.angle = next)),
),
cx,
)),
)
.child(
specimens()
.child(specimen(
"SelectionArea",
probe(
"interaction-tiles",
tiles.selected(picked).on_change(move |keys, _, cx| {
let keys = keys.to_vec();
change(&pick, cx, |shapes| shapes.picked = keys)
}),
),
cx,
))
.child(specimen(
"Sortable → motion::Reorder",
div().w(px(220.)).child(list.on_reorder(move |from, to, _, cx| {
change(&sort, cx, |shapes| {
let row = shapes.order.remove(from);
shapes.order.insert(to, row);
})
})),
cx,
)),
)
.child(
section(
"LongPress · DoubleClick · Gesture · PanZoom",
"A long press is primitives::Pressable; a double press is gpui's click_count. The wheel pans and, with Command, zooms layout::Viewport; two fingers swipe lists::SwipeableListItem.",
cx,
)
.child(
specimens()
.child(specimen(
"LongPress → primitives::Pressable",
Pressable::new("interaction-press", move |down, _, cx| {
let theme = cx.theme();
div()
.px_4()
.py_3()
.rounded_lg()
.border_1()
.border_color(theme.colors.border)
.bg(if down { theme.colors.active } else { theme.colors.surface })
.text_size(theme.text_size(TextSize::Sm))
.child(format!("Pressed {presses} · held {held}"))
.into_any_element()
})
.on_press(move |_, cx| change(&press, cx, |shapes| shapes.presses += 1))
.on_long_press(move |_, cx| change(&long, cx, |shapes| shapes.held += 1)),
cx,
))
.child(specimen(
"DoubleClick",
div()
.id("interaction-double")
.px_4()
.py_3()
.rounded_lg()
.border_1()
.border_color(theme.colors.border)
.tab_index(0)
.focus_ring(cx)
.text_size(theme.text_size(TextSize::Sm))
.on_click(move |event, _, cx| {
if event.click_count() == 2 {
change(&double, cx, |shapes| shapes.doubles += 1)
}
})
.child(format!("Opened {doubles} times")),
cx,
))
.child(specimen(
"PanZoom → layout::Viewport",
div().w(px(240.)).h(px(140.)).rounded_lg().border_1().border_color(theme.colors.border).overflow_hidden().child(
Viewport::new("interaction-viewport", |view: Transform, _, cx| {
let theme = cx.theme();
let corner = view.apply(point(px(80.), px(30.)));
div()
.absolute()
.left(corner.x)
.top(corner.y)
.size(px(80.) * view.scale)
.rounded_lg()
.bg(theme.colors.sunken)
.into_any_element()
}),
),
cx,
)),
)
.child(blocked("Pinch: gpui 0.2.2 forwards no magnify event, so a trackpad pinch never reaches a view.", cx)),
)
}
+2
View File
@@ -18,6 +18,7 @@ mod finance;
mod forms;
mod generative;
mod git;
mod interaction;
mod layout;
mod lists;
mod mail;
@@ -89,6 +90,7 @@ pub const ALL: &[Page] = &[
settings::PAGE,
account::PAGE,
onboarding::PAGE,
interaction::PAGE,
theme::PAGE,
];
+10
View File
@@ -0,0 +1,10 @@
mod resizable;
mod rotatable;
mod selection;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use resizable::Resizable;
pub use rotatable::Rotatable;
pub use selection::SelectionArea;
+232
View File
@@ -0,0 +1,232 @@
use std::{cell::Cell, 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,
};
use smallvec::SmallVec;
use crate::{
layout::{resize_handle, seeded::use_seeded},
primitives::FocusRing,
theme::{ActiveTheme, Radius},
};
/// Which side a drag moves.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Side {
Right,
Bottom,
Corner,
}
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`.
fn resized(
start: Size<Pixels>,
side: Side,
delta: Point<Pixels>,
(min, max): (Size<Pixels>, Size<Pixels>),
) -> Size<Pixels> {
let width = match side {
Side::Bottom => start.width,
Side::Right | Side::Corner => (start.width + delta.x).clamp(min.width, max.width),
};
let height = match side {
Side::Right => start.height,
Side::Bottom | Side::Corner => (start.height + delta.y).clamp(min.height, max.height),
};
size(width, height)
}
type OnResize = Rc<dyn Fn(Size<Pixels>, &mut Window, &mut App)>;
/// A box the owner sizes, with handles on its right and bottom edges and a grip at its lower right corner. A drag resizes it within its least and greatest size, and the owner hears each new size; a new size from the owner replaces it. The grip is a Tab stop, and the arrows resize a step at a time.
#[derive(IntoElement)]
pub struct Resizable {
id: ElementId,
size: Size<Pixels>,
limits: (Size<Pixels>, Size<Pixels>),
children: SmallVec<[AnyElement; 1]>,
on_resize: Option<OnResize>,
}
impl Resizable {
/// `min` and `max` bound the size, which starts within them.
pub fn new(
id: impl Into<ElementId>,
size: Size<Pixels>,
min: Size<Pixels>,
max: Size<Pixels>,
) -> Self {
let id = id.into();
assert!(
min.width <= size.width && size.width <= max.width,
"resizable {id:?}: width {:?} is not within {:?} and {:?}",
size.width,
min.width,
max.width
);
assert!(
min.height <= size.height && size.height <= max.height,
"resizable {id:?}: height {:?} is not within {:?} and {:?}",
size.height,
min.height,
max.height
);
Self {
id,
size,
limits: (min, max),
children: SmallVec::new(),
on_resize: None,
}
}
pub fn on_resize(
mut self,
handler: impl Fn(Size<Pixels>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_resize = Some(Rc::new(handler));
self
}
}
impl ParentElement for Resizable {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for Resizable {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_resize = self
.on_resize
.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 (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);
held.update(cx, |held, cx| {
held.value = next;
cx.notify();
});
on_resize(next, window, cx);
});
let theme = cx.theme();
let (hit, grip) = (theme.handle_hit(), 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 (dragged, keyed) = (report.clone(), report);
div()
.id(id.clone())
.relative()
.w(now.width)
.h(now.height)
.on_drag_move(move |event: &DragMoveEvent<ResizeDrag>, window, cx| {
let drag = event.drag(cx);
if drag.owner != owner {
return;
}
let at = event.event.position;
let anchor = drag.anchor.get().unwrap_or_else(|| {
drag.anchor.set(Some(at));
at
});
let next = resized(drag.start, drag.side, at - anchor, limits);
if next != now {
dragged(next, window, cx);
}
})
.children(self.children)
.child(
resize_handle((id.clone(), "right"), Axis::Horizontal, cx)
.absolute()
.top_0()
.right(hit * -0.5)
.on_drag(drag(Side::Right), |_, _, _, cx| cx.new(|_| EmptyView)),
)
.child(
resize_handle((id.clone(), "bottom"), Axis::Vertical, cx)
.absolute()
.left_0()
.bottom(hit * -0.5)
.on_drag(drag(Side::Bottom), |_, _, _, cx| cx.new(|_| EmptyView)),
)
.child(
div()
.id((id, "corner"))
.absolute()
.right(grip * -0.5)
.bottom(grip * -0.5)
.size(grip)
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(theme.colors.border_strong)
.bg(theme.colors.surface)
.cursor(CursorStyle::ResizeUpLeftDownRight)
.tab_index(0)
.focus_ring(cx)
.on_key_down(move |event, window, cx| {
let delta = match event.keystroke.key.as_str() {
"left" => point(-step, Pixels::ZERO),
"right" => point(step, Pixels::ZERO),
"up" => point(Pixels::ZERO, -step),
"down" => point(Pixels::ZERO, step),
_ => return,
};
cx.stop_propagation();
let next = resized(now, Side::Corner, delta, limits);
if next != now {
keyed(next, window, cx);
}
})
.on_drag(drag(Side::Corner), |_, _, _, cx| cx.new(|_| EmptyView)),
)
}
}
#[cfg(test)]
mod tests {
use gpui::{point, px, size};
use super::{Side, resized};
#[test]
fn a_side_moves_its_own_measure_within_the_limits() {
let limits = (size(px(100.0), px(80.0)), size(px(400.0), px(300.0)));
let start = size(px(200.0), px(150.0));
let by = point(px(50.0), px(-500.0));
assert_eq!(
resized(start, Side::Right, by, limits),
size(px(250.0), px(150.0))
);
assert_eq!(
resized(start, Side::Bottom, by, limits),
size(px(200.0), px(80.0))
);
assert_eq!(
resized(start, Side::Corner, by, limits),
size(px(250.0), px(80.0))
);
let far = point(px(900.0), px(900.0));
assert_eq!(
resized(start, Side::Corner, far, limits),
size(px(400.0), px(300.0))
);
}
}
+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");
}
}
+346
View File
@@ -0,0 +1,346 @@
use std::{collections::HashMap, rc::Rc};
use gpui::{
AnyElement, App, AppContext, Bounds, Div, DragMoveEvent, ElementId, EmptyView, EntityId,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
};
use crate::{
primitives::{Measure, tab_stop},
theme::{ActiveTheme, Radius},
};
type OnSelect = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// Where the items sat when last drawn, the item the keyboard is on, where the last press on empty space fell, and the band while a drag draws it, from the box's corner.
#[derive(Default)]
struct Area {
items: HashMap<SharedString, Bounds<Pixels>>,
cursor: usize,
press: Option<Point<Pixels>>,
band: Option<(Point<Pixels>, Point<Pixels>)>,
}
/// Where a key takes the cursor among `count` items: on through the items' order, or back; the ends hold.
fn stepped(key: &str, at: usize, count: usize) -> Option<usize> {
match key {
"right" | "down" => Some((at + 1).min(count - 1)),
"left" | "up" => Some(at.saturating_sub(1)),
"home" => Some(0),
"end" => Some(count - 1),
_ => None,
}
}
struct Band {
owner: EntityId,
adding: Vec<SharedString>,
}
/// The rectangle between two corners.
fn spanned(from: Point<Pixels>, to: Point<Pixels>) -> Bounds<Pixels> {
Bounds::from_corners(
gpui::point(from.x.min(to.x), from.y.min(to.y)),
gpui::point(from.x.max(to.x), from.y.max(to.y)),
)
}
/// The keys, in the given order, of the items a band touches, after those it adds to.
fn banded(
order: &[SharedString],
items: &HashMap<SharedString, Bounds<Pixels>>,
band: Bounds<Pixels>,
adding: &[SharedString],
) -> Vec<SharedString> {
order
.iter()
.filter(|key| {
adding.contains(key) || items.get(*key).is_some_and(|item| item.intersects(&band))
})
.cloned()
.collect()
}
/// The selection after a press on `key`: it alone, or with Command or Shift, added or dropped.
fn pressed(selected: &[SharedString], key: &SharedString, adding: bool) -> Vec<SharedString> {
match (adding, selected.contains(key)) {
(false, _) => vec![key.clone()],
(true, true) => selected
.iter()
.filter(|each| *each != key)
.cloned()
.collect(),
(true, false) => selected.iter().chain([key]).cloned().collect(),
}
}
/// Items in a box, each under a key. A drag on the box's empty space draws a band that selects what it touches, Shift or Command adding to what was selected, and a press there clears; a press on an item selects it alone, and Command or Shift adds or drops it. The box is one Tab stop: the arrows walk a cursor through the items, ringed while the box has focus, Space adds or drops the one it is on, Escape clears and Command-A takes all. The owner lays the items out through the box's style and hears each new selection.
#[derive(IntoElement)]
pub struct SelectionArea {
id: ElementId,
base: Div,
items: Vec<(SharedString, AnyElement)>,
selected: Vec<SharedString>,
on_change: Option<OnSelect>,
}
impl SelectionArea {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
base: div(),
items: Vec::new(),
selected: Vec::new(),
on_change: None,
}
}
pub fn item(mut self, key: impl Into<SharedString>, item: impl IntoElement) -> Self {
self.items.push((key.into(), item.into_any_element()));
self
}
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.selected = keys.into_iter().map(Into::into).collect();
self
}
/// Gets the keys now selected, in the items' order.
pub fn on_change(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl Styled for SelectionArea {
fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
}
}
impl RenderOnce for SelectionArea {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_change = self
.on_change
.unwrap_or_else(|| panic!("selection area {id:?} has no on_change"));
let area = window.use_keyed_state((id.clone(), "area"), cx, |_, _| Area::default());
let owner = area.entity_id();
let focus = tab_stop((id.clone(), "focus").into(), true, window, cx);
let focused = focus.is_focused(window);
let order: Rc<[SharedString]> = self.items.iter().map(|(key, _)| key.clone()).collect();
let count = order.len();
let cursor = area.read(cx).cursor.min(count.saturating_sub(1));
let selected: Rc<[SharedString]> = self.selected.into();
let theme = cx.theme();
let (accent, ring, wash, radius) = (
theme.colors.accent,
theme.colors.focus,
theme.colors.selection,
theme.radius(Radius::Md),
);
let tell: OnSelect = Rc::new(move |keys, window, cx| {
log::info!("selection area: {} selected", keys.len());
on_change(keys, window, cx)
});
let items: Vec<_> = self
.items
.into_iter()
.enumerate()
.map(|(ix, (key, item))| {
let on = selected.contains(&key);
let (seen, press, now, held, keys_to, at) = (
area.clone(),
tell.clone(),
selected.clone(),
key.clone(),
focus.clone(),
area.clone(),
);
div()
.id((id.clone(), format!("item-{key}")))
.rounded(radius)
.border_1()
.border_color(if focused && ix == cursor {
ring
} else {
gpui::transparent_black()
})
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
cx.stop_propagation();
window.focus(&keys_to);
at.update(cx, |area, _| area.cursor = ix);
let adding = event.modifiers.platform || event.modifiers.shift;
press(&pressed(&now, &held, adding), window, cx)
})
.child(
div()
.rounded(radius)
.border_1()
.border_color(if on {
accent
} else {
gpui::transparent_black()
})
.child(
Measure::new(
(id.clone(), format!("measure-{key}")),
move |bounds, _, cx| {
let key = key.clone();
seen.update(cx, |area, _| {
area.items.insert(key, bounds);
})
},
)
.child(item),
),
)
})
.collect();
let band = area.read(cx).band.map(|(from, to)| {
let lit = spanned(from, to);
div()
.debug_selector(|| "selection-band".into())
.absolute()
.left(lit.origin.x)
.top(lit.origin.y)
.w(lit.size.width)
.h(lit.size.height)
.border_1()
.border_color(accent)
.bg(wash)
});
let (pressed_at, moved, ended, gone, walked) =
(area.clone(), area.clone(), area.clone(), area.clone(), area);
let (keys, keyed, emptied) = (tell.clone(), tell.clone(), tell);
let (all, adding, now) = (order.clone(), selected.to_vec(), selected.clone());
self.base
.id(id.clone())
.relative()
.track_focus(&focus)
.on_drag(Band { owner, adding }, |_, _, _, cx| cx.new(|_| EmptyView))
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
pressed_at.update(cx, |area, _| area.press = Some(event.position));
})
.on_drag_move(move |event: &DragMoveEvent<Band>, window, cx| {
let drag = event.drag(cx);
if drag.owner != owner {
return;
}
let origin = event.bounds.origin;
let at = event.event.position - origin;
let press = moved.read(cx).press.expect("a band starts from a press");
let start = press - origin;
let adding = match event.event.modifiers.platform || event.event.modifiers.shift {
true => drag.adding.clone(),
false => Vec::new(),
};
let lit = spanned(start + origin, at + origin);
let chosen = banded(&order, &moved.read(cx).items, lit, &adding);
moved.update(cx, |area, cx| {
area.band = Some((start, at));
cx.notify();
});
keys(&chosen, window, cx);
})
.on_mouse_up(MouseButton::Left, move |event, window, cx| {
let clicked = ended.update(cx, |area, cx| {
let clicked = area.band.is_none() && area.press.is_some();
area.band = None;
area.press = None;
cx.notify();
clicked
});
if clicked && !event.modifiers.platform && !event.modifiers.shift {
emptied(&[], window, cx);
}
})
.on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
gone.update(cx, |area, cx| {
area.band = None;
area.press = None;
cx.notify();
})
})
.on_key_down(move |event, window, cx| {
if count == 0 {
return;
}
let keystroke = &event.keystroke;
let key = keystroke.key.as_str();
if let Some(to) =
stepped(key, cursor, count).filter(|_| !keystroke.modifiers.modified())
{
cx.stop_propagation();
walked.update(cx, |area, cx| {
area.cursor = to;
cx.notify();
});
return;
}
match (key, keystroke.modifiers.platform) {
("space", false) => keyed(&pressed(&now, &all[cursor], true), window, cx),
("escape", false) => keyed(&[], window, cx),
("a", true) => keyed(&all, window, cx),
_ => return,
}
cx.stop_propagation();
})
.children(items)
.children(band)
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use gpui::{Bounds, SharedString, point, px, size};
use super::{banded, pressed, stepped};
fn keys(list: &[&str]) -> Vec<SharedString> {
list.iter()
.map(|key| SharedString::from(key.to_string()))
.collect()
}
#[test]
fn a_band_takes_what_it_touches_in_order_after_what_it_adds_to() {
let order = keys(&["a", "b", "c"]);
let tile = |x: f32| Bounds::new(point(px(x), px(0.0)), size(px(40.0), px(40.0)));
let items = HashMap::from([
(order[0].clone(), tile(0.0)),
(order[1].clone(), tile(50.0)),
(order[2].clone(), tile(100.0)),
]);
let band = Bounds::new(point(px(45.0), px(10.0)), size(px(60.0), px(5.0)));
assert_eq!(banded(&order, &items, band, &[]), keys(&["b", "c"]));
assert_eq!(
banded(&order, &items, band, &keys(&["a"])),
keys(&["a", "b", "c"])
);
}
#[test]
fn keys_walk_the_cursor_and_hold_at_the_ends() {
assert_eq!(stepped("right", 0, 3), Some(1));
assert_eq!(stepped("down", 2, 3), Some(2), "the end holds");
assert_eq!(stepped("left", 0, 3), Some(0));
assert_eq!(stepped("end", 0, 3), Some(2));
assert_eq!(stepped("space", 1, 3), None);
}
#[test]
fn a_press_selects_alone_or_adds_and_drops() {
let now = keys(&["a"]);
let b = SharedString::from("b");
assert_eq!(pressed(&now, &b, false), keys(&["b"]));
assert_eq!(pressed(&now, &b, true), keys(&["a", "b"]));
assert_eq!(pressed(&keys(&["a", "b"]), &b, true), keys(&["a"]));
}
}
+238
View File
@@ -0,0 +1,238 @@
use gpui::{
AnyElement, Context, Entity, IntoElement, KeyBinding, Modifiers, MouseButton, ParentElement,
Pixels, Point, Render, SharedString, Size, Styled, TestAppContext, VisualTestContext, Window,
div, point, px, size,
};
use super::{Resizable, Rotatable, SelectionArea};
use crate::{forms, primitives::FocusNext, theme::Theme};
type Part = fn(&Bench, Entity<Bench>) -> AnyElement;
/// A view that shows one interaction part and holds what the owner decides: a size, an angle and a selection.
struct Bench {
part: Part,
size: Size<Pixels>,
angle: f32,
selected: Vec<SharedString>,
}
impl Render for Bench {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().p(px(20.0)).child((self.part)(self, cx.entity()))
}
}
fn bench(part: Part, cx: &mut TestAppContext) -> (Entity<Bench>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
forms::bind_keys(cx);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
let (host, cx) = cx.add_window_view(|_, _| Bench {
part,
size: size(px(200.0), px(120.0)),
angle: 0.0,
selected: Vec::new(),
});
settle(cx);
(host, cx)
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..3 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn tab(stops: usize, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
(0..stops).for_each(|_| window.focus_next());
});
settle(cx);
}
fn tap(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
}
fn at(x: f32, y: f32) -> Point<Pixels> {
point(px(x), px(y))
}
/// Presses at `from`, moves through `path` with the button held, and lets go at its end.
fn drag(
from: Point<Pixels>,
path: &[Point<Pixels>],
modifiers: Modifiers,
cx: &mut VisualTestContext,
) {
cx.simulate_mouse_down(from, MouseButton::Left, modifiers);
for step in path {
cx.simulate_mouse_move(*step, Some(MouseButton::Left), modifiers);
settle(cx);
}
let end = *path.last().expect("a path");
cx.simulate_mouse_up(end, MouseButton::Left, modifiers);
settle(cx);
}
fn resizable(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
Resizable::new(
"box",
bench.size,
size(px(100.0), px(80.0)),
size(px(300.0), px(200.0)),
)
.on_resize(move |next, _, cx| {
owner.update(cx, |bench, cx| {
bench.size = next;
cx.notify();
})
})
.child(div().size_full())
.into_any_element()
}
/// The grip is the one stop; the arrows resize a step, and a drag on the right edge only widens.
#[gpui::test]
fn a_box_resizes_from_its_grip_and_its_edge_within_its_limits(cx: &mut TestAppContext) {
let (host, cx) = bench(resizable, cx);
tab(1, cx);
tap("right", cx);
tap("down", cx);
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)],
Modifiers::none(),
cx,
);
let dragged = host.read_with(cx, |bench, _| bench.size);
assert_eq!(
dragged,
size(px(300.0), px(128.0)),
"the edge stops at the widest"
);
}
fn rotatable(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
Rotatable::new("dial", "icons/arrow-up.svg", px(100.0), bench.angle)
.on_turn(move |next, _, cx| {
owner.update(cx, |bench, cx| {
bench.angle = next;
cx.notify();
})
})
.into_any_element()
}
/// The ring is the one stop; Left and Right turn it a notch, round the circle.
#[gpui::test]
fn a_dial_turns_by_notches_from_the_keyboard(cx: &mut TestAppContext) {
let (host, cx) = bench(rotatable, cx);
tab(1, cx);
tap("left", cx);
assert_eq!(host.read_with(cx, |bench, _| bench.angle), 345.0);
tap("right", cx);
tap("right", cx);
assert_eq!(host.read_with(cx, |bench, _| bench.angle), 15.0);
}
/// The knob sits on top of a ring centered at 70, 70; a drag to 86 degrees with Shift snaps to 90.
#[gpui::test]
fn a_dial_turns_toward_its_dragged_knob(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::shift(),
cx,
);
assert_eq!(host.read_with(cx, |bench, _| bench.angle), 90.0);
}
fn area(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
["a", "b", "c"]
.into_iter()
.fold(
SelectionArea::new("tiles")
.flex()
.gap(px(10.0))
.p(px(20.0))
.w(px(300.0)),
|area, key| area.item(key, div().size(px(40.0))),
)
.selected(bench.selected.clone())
.on_change(move |keys, _, cx| {
let keys = keys.to_vec();
owner.update(cx, |bench, cx| {
bench.selected = keys;
cx.notify();
})
})
.into_any_element()
}
fn picked(host: &Entity<Bench>, cx: &mut VisualTestContext) -> Vec<String> {
host.read_with(cx, |bench, _| {
bench.selected.iter().map(ToString::to_string).collect()
})
}
/// Tiles sit at x 41, 93 and 145 inside the box, which starts at 20; a band from empty space below takes the two it crosses.
#[gpui::test]
fn a_band_selects_what_it_crosses(cx: &mut TestAppContext) {
let (host, cx) = bench(area, cx);
drag(
at(100.0, 100.0),
&[at(110.0, 95.0), at(150.0, 70.0), at(160.0, 60.0)],
Modifiers::none(),
cx,
);
assert_eq!(picked(&host, cx), ["b", "c"]);
assert!(cx.debug_bounds("selection-band").is_some());
}
/// A press selects a tile alone, Command adds another, a press on empty space clears, and with focus Command-A takes all and Escape clears.
#[gpui::test]
fn presses_and_keys_pick_tiles(cx: &mut TestAppContext) {
let (host, cx) = bench(area, cx);
cx.simulate_click(at(60.0, 60.0), Modifiers::none());
settle(cx);
assert_eq!(picked(&host, cx), ["a"]);
cx.simulate_click(at(112.0, 60.0), Modifiers::command());
settle(cx);
assert_eq!(picked(&host, cx), ["a", "b"]);
cx.simulate_click(at(250.0, 100.0), Modifiers::none());
settle(cx);
assert!(
picked(&host, cx).is_empty(),
"a press on empty space clears"
);
tap("cmd-a", cx);
assert_eq!(picked(&host, cx), ["a", "b", "c"]);
tap("escape", cx);
assert!(picked(&host, cx).is_empty());
}
/// 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) {
let (host, cx) = bench(area, cx);
tab(1, cx);
tap("right", cx);
tap("space", cx);
tap("right", cx);
tap("space", cx);
assert_eq!(picked(&host, cx), ["b", "c"]);
tap("left", cx);
tap("space", cx);
assert_eq!(picked(&host, cx), ["c"]);
}
+1
View File
@@ -20,6 +20,7 @@ pub mod finance;
pub mod forms;
pub mod generative;
pub mod git;
pub mod interaction;
pub mod layout;
pub mod lists;
pub mod mail;
+21
View File
@@ -0,0 +1,21 @@
use gpui::Rems;
use super::{Theme, tokens::px_to_rems};
/// Interaction's measures.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InteractionSizes {
/// What an arrow key resizes a box by.
pub step: Rems,
/// A resize corner's grip, square.
pub grip: Rems,
}
impl Theme {
pub fn interaction(&self) -> InteractionSizes {
InteractionSizes {
step: px_to_rems(8.0),
grip: px_to_rems(12.0),
}
}
}
+2
View File
@@ -4,6 +4,7 @@ mod chart;
mod dashboard;
mod files;
mod generative;
mod interaction;
mod media;
mod messaging;
mod onboarding;
@@ -22,6 +23,7 @@ pub use chart::ChartSizes;
pub use dashboard::DashboardSizes;
pub use files::FileSizes;
pub use generative::GenerativeSizes;
pub use interaction::InteractionSizes;
pub use media::MediaSizes;
pub use messaging::MessagingSizes;
pub use onboarding::OnboardingSizes;
+17 -14
View File
@@ -217,21 +217,24 @@
- [x] InlineHelp
- [x] EmptyStateWithAction → `feedback::EmptyState`, whose `action` adds its buttons
## T38 Interaction — `src/interaction`
## T38a Interaction · Pointer — `src/interaction`
- [x] Draggable — native `on_drag`
- [x] Droppable / DropTarget — native `on_drop`, `drag_over`, `can_drop`
- [x] DragOverlay / DragPreview — native drag view
- [x] DragGhost → DragPreview (native)
- [x] Sortable / SortableContext → `motion::Reorder`
- [x] Resizable
- [x] Rotatable — an svg: gpui 0.2.2 turns no other element
- [x] Selectable / SelectionArea
- [x] RubberBandSelection → `interaction::SelectionArea`
- [x] LongPress → `primitives::Pressable`
- [x] DoubleClick — native `ClickEvent::click_count`
- [x] Gesture — prove: wheel zoom through `layout::Viewport`, two-finger swipe through `lists::SwipeableListItem`; pinch blocked, gpui 0.2.2 forwards no magnify event
- [x] PanZoom → `layout::Viewport`
## T38b Interaction · Keys, scroll and focus
- [ ] Draggable — native `on_drag`
- [ ] Droppable / DropTarget — native `on_drop`, `drag_over`
- [ ] DragOverlay / DragPreview — native drag view
- [ ] DragGhost → DragPreview (native)
- [ ] Sortable / SortableContext
- [ ] Resizable
- [ ] Rotatable
- [ ] Selectable / SelectionArea
- [ ] RubberBandSelection → `interaction::SelectionArea`
- [ ] LongPress → `primitives::Pressable`
- [ ] DoubleClick — native `ClickEvent::click_count`
- [ ] Gesture — prove: wheel zoom, pinch, two-finger swipe
- [ ] PanZoom → `layout::Viewport`
- [ ] Hotkeys / KeyBinding — native `actions!`, `bind_keys`, `key_context`
- [ ] KeyChord — native multi-keystroke bindings
- [ ] Undo / Redo Manager