diff --git a/TASKS.md b/TASKS.md index c0eab50..7f48253 100644 --- a/TASKS.md +++ b/TASKS.md @@ -414,3 +414,4 @@ Per-component lines live in `tasks/`. Tags there: | T37a | 2 | PASS | | | T37b | 1 | FAIL | the help panel's rows were bare Tab stops, not a list the keyboard walks; a long support address drew past 280px | | T37b | 2 | PASS | | +| T38a | 1 | FAIL | the resize grip and edge handles hung half outside the box; the edges' sides, a free turn and a band with Shift untested | diff --git a/src/interaction/resizable.rs b/src/interaction/resizable.rs index b17b66d..e6f94c3 100644 --- a/src/interaction/resizable.rs +++ b/src/interaction/resizable.rs @@ -1,14 +1,14 @@ -use std::{cell::Cell, rc::Rc}; +use std::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, + InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, + RenderOnce, Size, StatefulInteractiveElement, Styled, Window, div, point, size, }; use smallvec::SmallVec; use crate::{ - layout::{resize_handle, seeded::use_seeded}, + layout::{resize_edge, seeded::use_seeded}, primitives::FocusRing, theme::{ActiveTheme, Radius}, }; @@ -25,7 +25,6 @@ struct ResizeDrag { owner: EntityId, side: Side, start: Size, - anchor: Rc>>>, } /// The size after moving `side` by `delta` from `start`, kept within `min` and `max`. @@ -113,6 +112,8 @@ impl RenderOnce for Resizable { .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 pressed = + window.use_keyed_state((id.clone(), "press"), cx, |_, _| None::>); 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); @@ -123,17 +124,26 @@ impl RenderOnce for Resizable { on_resize(next, window, cx); }); let theme = cx.theme(); - let (hit, grip) = (theme.handle_hit(), theme.interaction().grip); + let grip = 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 grab = |side| { + let drag = ResizeDrag { + owner, + side, + start: now, + }; + let at = pressed.clone(); + let press = move |event: &MouseDownEvent, _: &mut Window, cx: &mut App| { + at.update(cx, |at, _| *at = Some(event.position)) + }; + (drag, press) }; + let ((right, right_press), (bottom, bottom_press), (corner, corner_press)) = + (grab(Side::Right), grab(Side::Bottom), grab(Side::Corner)); let (dragged, keyed) = (report.clone(), report); div() .id(id.clone()) + .debug_selector(|| "resizable-box".into()) .relative() .w(now.width) .h(now.height) @@ -143,10 +153,7 @@ impl RenderOnce for Resizable { return; } let at = event.event.position; - let anchor = drag.anchor.get().unwrap_or_else(|| { - drag.anchor.set(Some(at)); - at - }); + let anchor = pressed.read(cx).expect("a resize starts from a press"); let next = resized(drag.start, drag.side, at - anchor, limits); if next != now { dragged(next, window, cx); @@ -154,25 +161,30 @@ impl RenderOnce for Resizable { }) .children(self.children) .child( - resize_handle((id.clone(), "right"), Axis::Horizontal, cx) + resize_edge((id.clone(), "right"), Axis::Horizontal, cx) .absolute() .top_0() - .right(hit * -0.5) - .on_drag(drag(Side::Right), |_, _, _, cx| cx.new(|_| EmptyView)), + .right_0() + .justify_end() + .on_mouse_down(MouseButton::Left, right_press) + .on_drag(right, |_, _, _, cx| cx.new(|_| EmptyView)), ) .child( - resize_handle((id.clone(), "bottom"), Axis::Vertical, cx) + resize_edge((id.clone(), "bottom"), Axis::Vertical, cx) .absolute() .left_0() - .bottom(hit * -0.5) - .on_drag(drag(Side::Bottom), |_, _, _, cx| cx.new(|_| EmptyView)), + .bottom_0() + .items_end() + .on_mouse_down(MouseButton::Left, bottom_press) + .on_drag(bottom, |_, _, _, cx| cx.new(|_| EmptyView)), ) .child( div() .id((id, "corner")) + .debug_selector(|| "resizable-grip".into()) .absolute() - .right(grip * -0.5) - .bottom(grip * -0.5) + .right_0() + .bottom_0() .size(grip) .rounded(theme.radius(Radius::Sm)) .border_1() @@ -195,7 +207,8 @@ impl RenderOnce for Resizable { keyed(next, window, cx); } }) - .on_drag(drag(Side::Corner), |_, _, _, cx| cx.new(|_| EmptyView)), + .on_mouse_down(MouseButton::Left, corner_press) + .on_drag(corner, |_, _, _, cx| cx.new(|_| EmptyView)), ) } } diff --git a/src/interaction/rotatable.rs b/src/interaction/rotatable.rs index d28db3b..7492f50 100644 --- a/src/interaction/rotatable.rs +++ b/src/interaction/rotatable.rs @@ -174,6 +174,14 @@ mod tests { 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 eleven = point( + px(50.0 + 50.0 * 11f32.to_radians().sin()), + px(50.0 - 50.0 * 11f32.to_radians().cos()), + ); + assert!( + (angle_at(ring, eleven, false) - 11.0).abs() < 1e-3, + "free, it stays at 11°" + ); 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/tests.rs b/src/interaction/tests.rs index 58e6c6a..afeed32 100644 --- a/src/interaction/tests.rs +++ b/src/interaction/tests.rs @@ -109,16 +109,43 @@ fn a_box_resizes_from_its_grip_and_its_edge_within_its_limits(cx: &mut TestAppCo 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)], + at(225.0, 60.0), + &[at(240.0, 80.0), at(260.0, 100.0), at(400.0, 120.0)], Modifiers::none(), cx, ); - let dragged = host.read_with(cx, |bench, _| bench.size); + let widened = host.read_with(cx, |bench, _| bench.size); assert_eq!( - dragged, + widened, size(px(300.0), px(128.0)), - "the edge stops at the widest" + "the right edge only widens, and stops at the widest" + ); + drag( + at(100.0, 145.0), + &[at(150.0, 160.0), at(180.0, 180.0), at(200.0, 200.0)], + Modifiers::none(), + cx, + ); + let deepened = host.read_with(cx, |bench, _| bench.size); + assert_eq!( + deepened, + size(px(300.0), px(183.0)), + "the bottom edge only deepens, by the pointer's way from the press" + ); +} + +/// The grip and the edges' handles lie inside the box, the grip in its lower right corner. +#[gpui::test] +fn the_grip_lies_inside_the_box(cx: &mut TestAppContext) { + let (_, cx) = bench(resizable, cx); + let (grip, frame) = ( + cx.debug_bounds("resizable-grip").expect("the grip"), + cx.debug_bounds("resizable-box").expect("the box"), + ); + assert_eq!( + (grip.right(), grip.bottom()), + (frame.right(), frame.bottom()), + "the grip ends at the box's corner: {grip:?} in {frame:?}" ); } @@ -158,6 +185,23 @@ fn a_dial_turns_toward_its_dragged_knob(cx: &mut TestAppContext) { assert_eq!(host.read_with(cx, |bench, _| bench.angle), 90.0); } +/// Without Shift the same drag turns the dial to the pointer's own angle. +#[gpui::test] +fn a_free_drag_turns_to_the_pointer(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::none(), + cx, + ); + let angle = host.read_with(cx, |bench, _| bench.angle); + assert!( + (angle - 86.19).abs() < 0.05, + "turned to 86 degrees: {angle}" + ); +} + fn area(bench: &Bench, owner: Entity) -> AnyElement { ["a", "b", "c"] .into_iter() @@ -222,6 +266,24 @@ fn presses_and_keys_pick_tiles(cx: &mut TestAppContext) { assert!(picked(&host, cx).is_empty()); } +/// With "a" selected, a band over "c" with Shift adds it. +#[gpui::test] +fn a_band_with_shift_adds_to_the_selection(cx: &mut TestAppContext) { + let (host, cx) = bench(area, cx); + host.update(cx, |bench, cx| { + bench.selected = vec!["a".into()]; + cx.notify(); + }); + settle(cx); + drag( + at(200.0, 100.0), + &[at(190.0, 90.0), at(170.0, 70.0), at(160.0, 60.0)], + Modifiers::shift(), + cx, + ); + assert_eq!(picked(&host, cx), ["a", "c"]); +} + /// 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) { diff --git a/src/layout/mod.rs b/src/layout/mod.rs index b19afce..63da417 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -27,7 +27,7 @@ pub(crate) use scroll::{bring_into_view, reveal_when_focused}; pub use scroll_aids::{ScrollShadow, ScrollToTop, StickyHeader}; pub use sheet::{Drawer, Sheet}; pub use sidebar::Sidebar; -pub use split::{SplitPane, resize_handle}; +pub use split::{SplitPane, resize_edge, resize_handle}; pub use stack::{AspectRatio, Container, ZStack, h_stack, v_stack}; pub use surface::{Card, CardHeader, Fieldset, Frame, Panel, Section, Well}; pub use viewport::{Transform, Viewport}; diff --git a/src/layout/split.rs b/src/layout/split.rs index 5b46458..24d3cbc 100644 --- a/src/layout/split.rs +++ b/src/layout/split.rs @@ -1,7 +1,7 @@ use std::{cell::Cell, rc::Rc}; use gpui::{ - AnyElement, App, Axis, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId, + AnyElement, App, Axis, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, Stateful, StatefulInteractiveElement, Styled, Window, div, prelude::*, }; @@ -11,8 +11,17 @@ use crate::theme::ActiveTheme; /// Thin divider with a wide grab area. `axis` is the split's direction. pub fn resize_handle(id: impl Into, axis: Axis, cx: &App) -> Stateful { + handle(id, axis, cx.theme().colors.border, cx) +} + +/// A resize handle whose line shows only while hovered, on an edge whose box draws its own border. +pub fn resize_edge(id: impl Into, axis: Axis, cx: &App) -> Stateful { + handle(id, axis, gpui::transparent_black(), cx) +} + +fn handle(id: impl Into, axis: Axis, line: Hsla, cx: &App) -> Stateful { let theme = cx.theme(); - let (line, lit, hit) = (theme.colors.border, theme.colors.focus, theme.handle_hit()); + let (lit, hit) = (theme.colors.focus, theme.handle_hit()); let base = div() .id(id) .group("resize")