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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 15:42:03 -04:00
1 parent 9bde90a492
commit f513df6aaa
14 files changed
+1413 -16

No files matched your search

+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"]);
}