Panics · P38 Interaction: handlers in new; resizes fit the limits now, stale drags and leaders do nothing

This commit is contained in:
ZacharyZhang-NY committed 2026-10-07 06:39:05 -04:00
1 parent 8db6cecd5a
commit 513ea50946
10 files changed
+278 -135

No files matched your search

+10 -10
View File
@@ -246,12 +246,15 @@ pub fn focus(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
("strike", IconName::Strikethrough),
];
let toolbar = tools.into_iter().fold(
RovingFocus::new("interaction-tools", Axis::Horizontal)
.gap_0p5()
.p_0p5()
.rounded_lg()
.border_1()
.border_color(theme.colors.border),
RovingFocus::new("interaction-tools", Axis::Horizontal, move |key, _, cx| {
let key = key.clone();
change(&press, cx, |focusing| focusing.tool = key)
})
.gap_0p5()
.p_0p5()
.rounded_lg()
.border_1()
.border_color(theme.colors.border),
|bar, (key, icon)| {
let on = tool.as_ref() == key;
bar.item(
@@ -308,10 +311,7 @@ pub fn focus(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
"RovingFocus",
probe(
"interaction-tools",
toolbar.on_press(move |key, _, cx| {
let key = key.clone();
change(&press, cx, |focusing| focusing.tool = key)
}),
toolbar,
),
cx,
))
+15 -17
View File
@@ -162,15 +162,18 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
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),
SelectionArea::new("interaction-tiles", move |keys, _, cx| {
let keys = keys.to_vec();
change(&pick, cx, |shapes| shapes.picked = keys)
})
.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}")),
@@ -204,8 +207,7 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
"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))
Resizable::new("interaction-resizable", size_now, size(px(160.), px(96.)), size(px(360.), px(220.)), move |next, _, cx| change(&resize, cx, |shapes| shapes.size = next))
.child(
div()
.size_full()
@@ -225,8 +227,7 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
"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)),
Rotatable::new("interaction-rotatable", "icons/arrow-up.svg", px(120.), angle, move |next, _, cx| change(&turn, cx, |shapes| shapes.angle = next)),
),
cx,
)),
@@ -237,10 +238,7 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
"SelectionArea",
probe(
"interaction-tiles",
tiles.selected(picked).on_change(move |keys, _, cx| {
let keys = keys.to_vec();
change(&pick, cx, |shapes| shapes.picked = keys)
}),
tiles.selected(picked),
),
cx,
))
+32 -32
View File
@@ -27,22 +27,30 @@ struct ResizeDrag {
start: Size<Pixels>,
}
/// The size after moving `side` by `delta` from `start`, kept within `min` and `max`.
/// `size` within `min` and `max`.
fn fit(size: Size<Pixels>, (min, max): (Size<Pixels>, Size<Pixels>)) -> Size<Pixels> {
gpui::size(
size.width.clamp(min.width, max.width),
size.height.clamp(min.height, max.height),
)
}
/// The size after moving `side` by `delta` from `start`, both measures kept within the limits.
fn resized(
start: Size<Pixels>,
side: Side,
delta: Point<Pixels>,
(min, max): (Size<Pixels>, Size<Pixels>),
limits: (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),
Side::Right | Side::Corner => start.width + delta.x,
};
let height = match side {
Side::Right => start.height,
Side::Bottom | Side::Corner => (start.height + delta.y).clamp(min.height, max.height),
Side::Bottom | Side::Corner => start.height + delta.y,
};
size(width, height)
fit(size(width, height), limits)
}
type OnResize = Rc<dyn Fn(Size<Pixels>, &mut Window, &mut App)>;
@@ -54,7 +62,7 @@ pub struct Resizable {
size: Size<Pixels>,
limits: (Size<Pixels>, Size<Pixels>),
children: SmallVec<[AnyElement; 1]>,
on_resize: Option<OnResize>,
on_resize: OnResize,
}
impl Resizable {
@@ -64,38 +72,24 @@ impl Resizable {
size: Size<Pixels>,
min: Size<Pixels>,
max: Size<Pixels>,
on_resize: impl Fn(Size<Pixels>, &mut Window, &mut App) + 'static,
) -> 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
min.width <= max.width && min.height <= max.height,
"resizable {id:?}: limits {min:?} to {max:?} run backwards"
);
if fit(size, (min, max)) != size {
log::error!("resizable {id:?}: size {size:?} outside {min:?} to {max:?}; pegged");
}
Self {
id,
size,
limits: (min, max),
children: SmallVec::new(),
on_resize: None,
on_resize: Rc::new(on_resize),
}
}
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 {
@@ -107,14 +101,17 @@ impl ParentElement for Resizable {
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 on_resize = self.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::<Point<Pixels>>);
let (owner, now) = (held.entity_id(), held.read(cx).value);
let (owner, kept) = (held.entity_id(), held.read(cx).value);
let now = fit(kept, limits);
if now != kept {
log::info!("resizable: {kept:?} fits the limits at {now:?}");
held.update(cx, |held, _| held.value = now);
}
let report: OnResize = Rc::new(move |next, window, cx| {
log::info!("resizable: {:?} by {:?}", next.width, next.height);
held.update(cx, |held, cx| {
@@ -153,7 +150,10 @@ impl RenderOnce for Resizable {
return;
}
let at = event.event.position;
let anchor = pressed.read(cx).expect("a resize starts from a press");
let Some(anchor) = *pressed.read(cx) else {
log::error!("resizable: a drag with no press behind it");
return;
};
let next = resized(drag.start, drag.side, at - anchor, limits);
if next != now {
dragged(next, window, cx);
+4 -11
View File
@@ -35,7 +35,7 @@ pub struct Rotatable {
path: SharedString,
side: Pixels,
angle: f32,
on_turn: Option<OnTurn>,
on_turn: OnTurn,
}
impl Rotatable {
@@ -45,6 +45,7 @@ impl Rotatable {
path: impl Into<SharedString>,
side: Pixels,
angle: f32,
on_turn: impl Fn(f32, &mut Window, &mut App) + 'static,
) -> Self {
assert!(
angle.is_finite(),
@@ -55,23 +56,15 @@ impl Rotatable {
path: path.into(),
side,
angle: angle.rem_euclid(360.0),
on_turn: None,
on_turn: Rc::new(on_turn),
}
}
/// 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 on_turn = self.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| {
+8 -15
View File
@@ -35,17 +35,21 @@ pub struct RovingFocus {
axis: Axis,
base: Div,
items: Vec<(SharedString, AnyElement)>,
on_press: Option<OnKey>,
on_press: OnKey,
}
impl RovingFocus {
pub fn new(id: impl Into<ElementId>, axis: Axis) -> Self {
pub fn new(
id: impl Into<ElementId>,
axis: Axis,
on_press: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
Self {
id: id.into(),
axis,
base: div(),
items: Vec::new(),
on_press: None,
on_press: Rc::new(on_press),
}
}
@@ -53,15 +57,6 @@ impl RovingFocus {
self.items.push((key.into(), content.into_any_element()));
self
}
/// Runs with the key of the item pressed.
pub fn on_press(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_press = Some(Rc::new(handler));
self
}
}
impl Styled for RovingFocus {
@@ -73,9 +68,7 @@ impl Styled for RovingFocus {
impl RenderOnce for RovingFocus {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_press = self
.on_press
.unwrap_or_else(|| panic!("roving focus {id:?} has no on_press"));
let on_press = self.on_press;
assert!(!self.items.is_empty(), "roving focus {id:?} has no item");
let stop = window.use_keyed_state((id.clone(), "stop"), cx, |_, _| 0usize);
let count = self.items.len();
+11 -16
View File
@@ -82,17 +82,20 @@ pub struct SelectionArea {
base: Div,
items: Vec<(SharedString, AnyElement)>,
selected: Vec<SharedString>,
on_change: Option<OnSelect>,
on_change: OnSelect,
}
impl SelectionArea {
pub fn new(id: impl Into<ElementId>) -> Self {
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: None,
on_change: Rc::new(on_change),
}
}
@@ -105,15 +108,6 @@ impl SelectionArea {
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 {
@@ -125,9 +119,7 @@ impl Styled for SelectionArea {
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 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);
@@ -233,7 +225,10 @@ impl RenderOnce for SelectionArea {
}
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 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(),
+4 -1
View File
@@ -64,7 +64,10 @@ impl RenderOnce for ScrollSync {
let sync = canvas(
move |_, _, _| {
let Some(lead) = lead else { return };
let place = synced[lead].offset();
let Some(place) = synced.get(lead).map(|handle| handle.offset()) else {
log::error!("scroll sync: leader pane {lead} of {} left", synced.len());
return;
};
for (ix, handle) in synced.iter().enumerate() {
let now = handle.offset();
let next = along(now, place, axis);
+142
View File
@@ -0,0 +1,142 @@
use gpui::{
AnyElement, Axis, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Styled,
TestAppContext, div, point, px, size,
};
use super::{Bench, at, bench, picked, settle, tab, tap};
use crate::interaction::{Resizable, ScrollSync};
/// A box whose greatest width drops to 205 once the angle turns.
fn narrowing(bench: &Bench, _: Entity<Bench>) -> AnyElement {
let max_width = if bench.angle == 0.0 { 300.0 } else { 205.0 };
Resizable::new(
"box",
bench.size,
size(px(100.0), px(80.0)),
size(px(max_width), px(200.0)),
|_, _, _| {},
)
.child(div().size_full())
.into_any_element()
}
fn width(cx: &mut gpui::VisualTestContext) -> gpui::Pixels {
cx.debug_bounds("resizable-box")
.expect("the box")
.size
.width
}
#[gpui::test]
fn a_local_size_fits_limits_that_shrank(cx: &mut TestAppContext) {
let (host, cx) = bench(narrowing, cx);
tab(1, cx);
tap("right", cx);
assert_eq!(width(cx), px(208.0));
host.update(cx, |bench, cx| {
bench.angle = 1.0;
cx.notify();
});
settle(cx);
assert_eq!(width(cx), px(205.0));
}
#[gpui::test]
fn a_new_owner_size_past_the_limits_replaces_a_local_one(cx: &mut TestAppContext) {
let (host, cx) = bench(narrowing, cx);
host.update(cx, |bench, cx| {
bench.size.width = px(400.0);
cx.notify();
});
settle(cx);
assert_eq!(width(cx), px(300.0));
tab(1, cx);
tap("left", cx);
assert_eq!(width(cx), px(292.0));
host.update(cx, |bench, cx| {
bench.size.width = px(500.0);
cx.notify();
});
settle(cx);
assert_eq!(width(cx), px(300.0));
}
/// A box whose greatest height drops to 100 once the angle turns, and the heights it reports.
fn lowering(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
let max_height = if bench.angle == 0.0 { 200.0 } else { 100.0 };
Resizable::new(
"box",
bench.size,
size(px(100.0), px(80.0)),
size(px(300.0), px(max_height)),
move |next, _, cx| {
owner.update(cx, |bench, cx| {
bench.size = next;
bench
.selected
.push(format!("{}", f32::from(next.height)).into());
cx.notify();
})
},
)
.child(div().size_full())
.into_any_element()
}
#[gpui::test]
fn an_edge_drag_reports_both_measures_within_the_limits_now(cx: &mut TestAppContext) {
let (host, cx) = bench(lowering, cx);
let none = Modifiers::none();
cx.simulate_mouse_down(at(217.0, 60.0), MouseButton::Left, none);
for x in [230.0, 240.0] {
cx.simulate_mouse_move(at(x, 60.0), Some(MouseButton::Left), none);
settle(cx);
}
assert!(!picked(&host, cx).is_empty(), "the drag began");
host.update(cx, |bench, cx| {
bench.angle = 1.0;
bench.selected.clear();
cx.notify();
});
settle(cx);
cx.simulate_mouse_move(at(250.0, 60.0), Some(MouseButton::Left), none);
settle(cx);
assert_eq!(picked(&host, cx).last().map(String::as_str), Some("100"));
}
/// Panes that drop to two once the angle turns.
fn synced(bench: &Bench, _: Entity<Bench>) -> AnyElement {
let count = if bench.angle == 0.0 { 3 } else { 2 };
(0..count)
.fold(
ScrollSync::new("sync", Axis::Vertical)
.w(px(300.0))
.h(px(200.0)),
|sync, _| {
sync.pane(
div()
.flex()
.flex_col()
.children((0..40).map(|_| div().h(px(30.0)))),
)
},
)
.into_any_element()
}
#[gpui::test]
fn a_leader_pane_that_left_syncs_nothing(cx: &mut TestAppContext) {
let (host, cx) = bench(synced, cx);
cx.simulate_event(gpui::ScrollWheelEvent {
position: at(270.0, 100.0),
delta: gpui::ScrollDelta::Pixels(point(px(0.0), px(-300.0))),
modifiers: Modifiers::none(),
touch_phase: gpui::TouchPhase::Moved,
});
settle(cx);
host.update(cx, |bench, cx| {
bench.angle = 1.0;
cx.notify();
});
settle(cx);
}
@@ -7,6 +7,8 @@ use gpui::{
use super::{Resizable, Rotatable, RovingFocus, ScrollSync, SelectionArea};
use crate::{forms, primitives::FocusNext, theme::Theme};
mod limits;
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.
@@ -88,13 +90,13 @@ fn resizable(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
bench.size,
size(px(100.0), px(80.0)),
size(px(300.0), px(200.0)),
move |next, _, cx| {
owner.update(cx, |bench, cx| {
bench.size = next;
cx.notify();
})
},
)
.on_resize(move |next, _, cx| {
owner.update(cx, |bench, cx| {
bench.size = next;
cx.notify();
})
})
.child(div().size_full())
.into_any_element()
}
@@ -135,6 +137,18 @@ fn a_box_resizes_from_its_grip_and_its_edge_within_its_limits(cx: &mut TestAppCo
}
/// The grip and the edges' handles lie inside the box, the grip in its lower right corner.
#[gpui::test]
fn a_size_past_its_limits_pegs_and_draws(cx: &mut TestAppContext) {
let (host, cx) = bench(resizable, cx);
host.update(cx, |bench, cx| {
bench.size = size(px(900.0), px(10.0));
cx.notify();
});
settle(cx);
let shown = cx.debug_bounds("resizable-box").expect("the box");
assert_eq!(shown.size, size(px(300.0), px(80.0)));
}
#[gpui::test]
fn the_grip_lies_inside_the_box(cx: &mut TestAppContext) {
let (_, cx) = bench(resizable, cx);
@@ -150,14 +164,19 @@ fn the_grip_lies_inside_the_box(cx: &mut TestAppContext) {
}
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| {
Rotatable::new(
"dial",
"icons/arrow-up.svg",
px(100.0),
bench.angle,
move |next, _, cx| {
owner.update(cx, |bench, cx| {
bench.angle = next;
cx.notify();
})
})
.into_any_element()
},
)
.into_any_element()
}
/// The ring is the one stop; Left and Right turn it a notch, round the circle.
@@ -206,21 +225,20 @@ 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)),
SelectionArea::new("tiles", move |keys, _, cx| {
let keys = keys.to_vec();
owner.update(cx, |bench, cx| {
bench.selected = keys;
cx.notify();
})
})
.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()
}
@@ -315,16 +333,16 @@ fn roving(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
let _ = bench;
["a", "b", "c"]
.into_iter()
.fold(RovingFocus::new("tools", Axis::Horizontal), |group, key| {
group.item(key, div().size(px(24.0)))
})
.on_press(move |key, _, cx| {
let key = key.clone();
owner.update(cx, |bench, cx| {
bench.selected.push(key);
cx.notify();
})
})
.fold(
RovingFocus::new("tools", Axis::Horizontal, move |key, _, cx| {
let key = key.clone();
owner.update(cx, |bench, cx| {
bench.selected.push(key);
cx.notify();
})
}),
|group, key| group.item(key, div().size(px(24.0))),
)
.into_any_element()
}
+2 -1
View File
@@ -52,7 +52,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca
- [x] P35 Settings — `src/settings`
- [x] P36 Account — `src/account`
- [x] P37 Onboarding — `src/onboarding`
- [ ] P38 Interaction — `src/interaction`
- [x] P38 Interaction — `src/interaction`
- [ ] P39 Theme — `src/theme`
- [ ] P40 i18n & a11y — `src/i18n`
- [ ] P41 Maps — `src/maps`
@@ -103,3 +103,4 @@ One line per item: what changed, what stayed and why, review rounds.
- P35: eighteen settings components take their handlers in `new`; a section or syntax theme not offered marks none; a font size or default outside its range pegs. Kept: options offered by construction, the proxy's Apply behind its read, drops that bring a file. Review: 1 round.
- P36: seventeen account components take their handlers in `new`, the email step's `on_send` with them; a busy provider, a current account or workspace, and a role no longer listed mark none and log; an invitation waits for its role to be offered; an expiry year that overflows reads as unread. Kept: quotas, providers, one current session, roles to invite as, the card behind Save. Review: 2 rounds.
- P37: seven onboarding components take their handlers in `new`, OnboardingWizard's `on_step` and `on_finish` among them; a help article no longer listed opens none; a wizard step past its last says so; a support topic no longer offered counts as none and holds Send. Kept: tasks, steps, releases and topics present, sentiments by construction, Send behind a pick. Review: 2 rounds.
- P38: Resizable, Rotatable, RovingFocus and SelectionArea take their handlers in `new`; a resize or band whose press state is gone moves nothing; Resizable keeps the owner's raw size as its seed and fits what it draws and reports to this frame's limits; ScrollSync's leader that left syncs nothing. Kept: finite angles, items and panes present. Review: 2 rounds.