Files
Ely-GPUI-Components/src/interaction/selection.rs
T

342 lines
13 KiB
Rust

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: OnSelect,
}
impl SelectionArea {
pub fn new(
id: impl Into<ElementId>,
on_change: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
base: div(),
items: Vec::new(),
selected: Vec::new(),
on_change: Rc::new(on_change),
}
}
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
}
}
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;
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, cx);
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 Some(press) = moved.read(cx).press else {
log::error!("selection area: a band with no press behind it");
return;
};
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"]));
}
}