From 040fc94f4922defea1b963d35be76eb39508fc38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 15:42:03 -0400 Subject: [PATCH] =?UTF-8?q?Interaction=20=C2=B7=20Pointer:=20resize,=20tur?= =?UTF-8?q?n,=20band=20selection,=20and=20gpui's=20own=20drag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 3 +- TASKS.md | 3 +- examples/gallery/pages/interaction/mod.rs | 35 ++ examples/gallery/pages/interaction/pointer.rs | 325 ++++++++++++++++ examples/gallery/pages/mod.rs | 2 + src/interaction/mod.rs | 10 + src/interaction/resizable.rs | 232 ++++++++++++ src/interaction/rotatable.rs | 180 +++++++++ src/interaction/selection.rs | 346 ++++++++++++++++++ src/interaction/tests.rs | 238 ++++++++++++ src/lib.rs | 1 + src/theme/interaction.rs | 21 ++ src/theme/mod.rs | 2 + tasks/ch31-43.md | 31 +- 14 files changed, 1413 insertions(+), 16 deletions(-) create mode 100644 examples/gallery/pages/interaction/mod.rs create mode 100644 examples/gallery/pages/interaction/pointer.rs create mode 100644 src/interaction/mod.rs create mode 100644 src/interaction/resizable.rs create mode 100644 src/interaction/rotatable.rs create mode 100644 src/interaction/selection.rs create mode 100644 src/interaction/tests.rs create mode 100644 src/theme/interaction.rs diff --git a/AGENTS.md b/AGENTS.md index 5d5e3cf..cf936cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TASKS.md b/TASKS.md index 3927ddc..c0eab50 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/examples/gallery/pages/interaction/mod.rs b/examples/gallery/pages/interaction/mod.rs new file mode 100644 index 0000000..5a265eb --- /dev/null +++ b/examples/gallery/pages/interaction/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/interaction/pointer.rs b/examples/gallery/pages/interaction/pointer.rs new file mode 100644 index 0000000..6667c14 --- /dev/null +++ b/examples/gallery/pages/interaction/pointer.rs @@ -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) -> 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::() + .is_some_and(|chip| chip.from != lane) + }) + .drag_over::(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::(…) · can_drop(…) · on_drop(…)", cx)) +} + +/// The pointer demos' state. +struct Shapes { + size: gpui::Size, + angle: f32, + picked: Vec, + 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)), + ) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 5c473a2..dafe565 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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, ]; diff --git a/src/interaction/mod.rs b/src/interaction/mod.rs new file mode 100644 index 0000000..95ceaed --- /dev/null +++ b/src/interaction/mod.rs @@ -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; diff --git a/src/interaction/resizable.rs b/src/interaction/resizable.rs new file mode 100644 index 0000000..b17b66d --- /dev/null +++ b/src/interaction/resizable.rs @@ -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, + anchor: Rc>>>, +} + +/// The size after moving `side` by `delta` from `start`, kept within `min` and `max`. +fn resized( + start: Size, + side: Side, + delta: Point, + (min, max): (Size, Size), +) -> Size { + 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, &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, + limits: (Size, Size), + children: SmallVec<[AnyElement; 1]>, + on_resize: Option, +} + +impl Resizable { + /// `min` and `max` bound the size, which starts within them. + pub fn new( + id: impl Into, + size: Size, + min: Size, + max: Size, + ) -> 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, &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) { + 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, 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)) + ); + } +} diff --git a/src/interaction/rotatable.rs b/src/interaction/rotatable.rs new file mode 100644 index 0000000..d28db3b --- /dev/null +++ b/src/interaction/rotatable.rs @@ -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, at: Point, 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; + +/// 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, +} + +impl Rotatable { + /// `path` names the svg asset; `side` is the ring's width. + pub fn new( + id: impl Into, + path: impl Into, + 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, 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"); + } +} diff --git a/src/interaction/selection.rs b/src/interaction/selection.rs new file mode 100644 index 0000000..705911f --- /dev/null +++ b/src/interaction/selection.rs @@ -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; + +/// 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>, + cursor: usize, + press: Option>, + band: Option<(Point, Point)>, +} + +/// 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 { + 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, +} + +/// The rectangle between two corners. +fn spanned(from: Point, to: Point) -> Bounds { + 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>, + band: Bounds, + adding: &[SharedString], +) -> Vec { + 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 { + 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, + on_change: Option, +} + +impl SelectionArea { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + base: div(), + items: Vec::new(), + selected: Vec::new(), + on_change: None, + } + } + + pub fn item(mut self, key: impl Into, item: impl IntoElement) -> Self { + self.items.push((key.into(), item.into_any_element())); + self + } + + pub fn selected(mut self, keys: impl IntoIterator>) -> 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, 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 { + 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"])); + } +} diff --git a/src/interaction/tests.rs b/src/interaction/tests.rs new file mode 100644 index 0000000..58e6c6a --- /dev/null +++ b/src/interaction/tests.rs @@ -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) -> 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, + angle: f32, + selected: Vec, +} + +impl Render for Bench { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div().p(px(20.0)).child((self.part)(self, cx.entity())) + } +} + +fn bench(part: Part, cx: &mut TestAppContext) -> (Entity, &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 { + 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, + path: &[Point], + 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) -> 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) -> 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) -> 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, cx: &mut VisualTestContext) -> Vec { + 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"]); +} diff --git a/src/lib.rs b/src/lib.rs index e380b03..4518e8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/theme/interaction.rs b/src/theme/interaction.rs new file mode 100644 index 0000000..0f1780e --- /dev/null +++ b/src/theme/interaction.rs @@ -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), + } + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 35c6724..923066f 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -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; diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 810c534..ff67e88 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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