Skip to content

Commit aa786e3

Browse files
authored
fix: keep message action bars stable under the pointer
Hover follows the pointer onto the raised action bar. The bar occluding the pane is not treated as the pointer leaving, and the tracking listener does not consume button presses.
1 parent 712e2db commit aa786e3

3 files changed

Lines changed: 59 additions & 27 deletions

File tree

‎crates/tinyclient/src/gui/conversation.rs‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,7 +1145,17 @@ impl Render for Pane {
11451145
.size_full();
11461146
let after = gpui::canvas(
11471147
|_, _, _| (),
1148-
move |_, (), window, cx| this.update(cx, |this, cx| this.after_layout(window, cx)),
1148+
move |_, (), window, cx| {
1149+
this.update(cx, |this, cx| this.after_layout(window, cx));
1150+
let pane = this.clone();
1151+
// Track geometry before hit testing/occlusion can hide the pane behind
1152+
// its own toolbar. Do not consume the event: buttons still receive it.
1153+
window.on_mouse_event(move |event: &gpui::MouseMoveEvent, phase, _, cx| {
1154+
if phase == gpui::DispatchPhase::Capture {
1155+
pane.update(cx, |this, cx| this.point_at(event.position, cx));
1156+
}
1157+
});
1158+
},
11491159
)
11501160
.absolute()
11511161
.size_0();
@@ -1173,10 +1183,10 @@ impl Render for Pane {
11731183
}
11741184
}),
11751185
)
1176-
.on_mouse_move(cx.listener(|this, event: &gpui::MouseMoveEvent, _, cx| this.point_at(event.position, cx)))
1177-
.on_hover(cx.listener(|this, hovered: &bool, _, cx| {
1186+
.on_hover(cx.listener(|this, hovered: &bool, window, cx| {
11781187
if !*hovered {
1179-
this.unpoint(cx);
1188+
// Occlusion is not a pointer exit; recheck the row/toolbar bounds.
1189+
this.point_at(window.mouse_position(), cx);
11801190
}
11811191
}))
11821192
.child(rows)

‎crates/tinyclient/src/gui/conversation/actions.rs‎

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ use std::rc::Rc;
2424
const TOOLBAR_RISE: f32 = 14.;
2525
const TOOLBAR_REACH: f32 = 320.;
2626

27+
/// Hover ownership is geometric: the toolbar occludes the pane's hitbox, but
28+
/// crossing onto its buttons must keep the current hover owner.
29+
fn pointed_row(position: Point<Pixels>, viewport: Bounds<Pixels>, seen: &[(usize, Bounds<Pixels>)], held: Option<usize>) -> Option<usize> {
30+
if !viewport.contains(&position) { return None; }
31+
let toolbar = held.and_then(|held| seen.iter().find(|(ix, bounds)| {
32+
let rise = Bounds::from_corners(
33+
point(bounds.right() - px(TOOLBAR_REACH), bounds.top() - px(TOOLBAR_RISE)),
34+
point(bounds.right(), bounds.top() + px(TOOLBAR_RISE + 8.)),
35+
);
36+
*ix == held && rise.contains(&position)
37+
}));
38+
toolbar.or_else(|| seen.iter().find(|(_, bounds)| bounds.contains(&position))).map(|(ix, _)| *ix)
39+
}
40+
2741
/// The context menu of a message, where the pointer was.
2842
pub struct Menu {
2943
pub message: Rc<Message>,
@@ -197,36 +211,19 @@ impl Pane {
197211
if self.script.holds_hover() {
198212
return;
199213
}
200-
let viewport = self.list.viewport_bounds();
201-
let over = viewport.contains(&position).then(|| {
214+
let over = {
202215
let seen = self.seen.borrow();
203-
let row = |bounds: &Bounds<Pixels>| bounds.contains(&position);
204-
// The toolbar rises over the row above; the pointer on it is still on its own row.
205-
let held = self.hover.as_ref().and_then(|id| {
206-
seen.iter().find(|(ix, bounds)| {
207-
let rise = Bounds::from_corners(
208-
point(bounds.right() - px(TOOLBAR_REACH), bounds.top() - px(TOOLBAR_RISE)),
209-
point(bounds.right(), bounds.top() + px(TOOLBAR_RISE + 8.)),
210-
);
211-
rise.contains(&position) && self.items.get(*ix).and_then(Item::message).is_some_and(|m| m.id == *id)
212-
})
213-
});
214-
let found = held.or_else(|| seen.iter().find(|(_, bounds)| row(bounds)));
215-
found.and_then(|(ix, _)| self.items.get(*ix)).and_then(Item::message).filter(|m| self.can(m).act).map(|m| m.id.clone())
216-
});
217-
let over = over.flatten();
216+
let held = self.hover.as_ref().and_then(|id| seen.iter().find(|(ix, _)| self.items.get(*ix).and_then(Item::message).is_some_and(|m| m.id == *id))).map(|(ix, _)| *ix);
217+
pointed_row(position, self.list.viewport_bounds(), &seen, held)
218+
.and_then(|ix| self.items.get(ix)).and_then(Item::message)
219+
.filter(|m| self.can(m).act).map(|m| m.id.clone())
220+
};
218221
if over != self.hover {
219222
self.hover = over;
220223
cx.notify();
221224
}
222225
}
223226

224-
pub(super) fn unpoint(&mut self, cx: &mut Context<Self>) {
225-
if self.menu.is_none() && self.picker.is_none() && self.hover.take().is_some() {
226-
cx.notify();
227-
}
228-
}
229-
230227
// The toolbar.
231228

232229
/// A menu, picker, dialog, viewer or a window-level layer is up: no toolbar shows.
@@ -731,6 +728,26 @@ impl Pane {
731728
mod tests {
732729
use super::*;
733730

731+
#[test]
732+
fn crossing_onto_toolbar_buttons_keeps_the_same_message() {
733+
let viewport = Bounds::from_corners(point(px(0.), px(0.)), point(px(800.), px(600.)));
734+
let seen = [
735+
(0, Bounds::from_corners(point(px(0.), px(100.)), point(px(800.), px(150.)))),
736+
(1, Bounds::from_corners(point(px(0.), px(150.)), point(px(800.), px(200.)))),
737+
];
738+
let mut held = pointed_row(point(px(400.), px(175.)), viewport, &seen, None);
739+
assert_eq!(held, Some(1));
740+
for position in [point(px(700.), px(151.)), point(px(700.), px(140.)), point(px(760.), px(140.))] {
741+
// The latter points are above the message, in the occluding toolbar.
742+
held = pointed_row(position, viewport, &seen, held);
743+
assert_eq!(held, Some(1));
744+
assert_eq!(pointed_row(position, viewport, &seen, held), held, "stationary redraw/press keeps the button's owner");
745+
}
746+
assert_eq!(pointed_row(point(px(100.), px(140.)), viewport, &seen, held), Some(0));
747+
assert_eq!(pointed_row(point(px(801.), px(140.)), viewport, &seen, held), None);
748+
assert_eq!(pointed_row(point(px(700.), px(-1.)), viewport, &seen, held), None);
749+
}
750+
734751
#[test]
735752
fn a_message_link_is_built_from_ids_where_the_network_has_one() {
736753
let mut key = protocol::Key { network: Network::Discord, account: "1".into(), id: "C9".into() };

‎docs/design/polish/01-system.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@ toolbar hides. Destructive items sit last after a separator, `danger` text, `dan
6161
the only item (`menu-net` gains "Open in Discord" and "Account settings…" above "Sign out"). **Popover**: S§3, 200–360
6262
wide (pickers declare their own size), 6 from the anchor, no arrow.
6363

64+
**Message action bar.** Hover ownership follows pointer geometry across the
65+
message and its raised action bar. The bar's occlusion of the pane does not
66+
count as leaving it. Moving onto a button or pressing it keeps the same message;
67+
leaving the pane clears hover. Pointer tracking does not consume button events.
68+
6469
**Buttons.** S§3: primary, secondary, ghost, danger, icon. Accept = small primary; Decline = small secondary; no green
6570
buttons, no full-radius controls (Leave, hang up, jump-to-bottom and viewer discs become radius 6 / 8 squares).
6671
In-caption actions ("Retry") are small ghost buttons.

0 commit comments

Comments
 (0)