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,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"]));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user