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:
1 parent
8ade21b7ba
commit
040fc94f49
14 files changed
+1413
-16
No files matched your search
@@ -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;
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"]));
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user