fix(glfw): route a drag by the row it began in, and drop stale hover state

`controller_at()` routed every pointer event by the row the pixel was in,
ignoring the row a drag started in, so a press on one figure row followed by a
motion and release in another delivered the motion and release to a controller
whose `mode_ == None`. The owning controller kept `mode_`, `drag_region_`,
`drag_cursor_` and `drag_fixed_x_` set for ever: `publish_hot()` kept publishing
the grabbed handle, `grabbed_region()` reported a drag that had ended, and a
later gesture re-entering the row resumed the stale edit from the old anchor.

`feed_pointer_event()` now owns the routing: a press captures the row it landed
in and every motion and the release until the button comes up go to that row
(`pointer_target()`), cleared on release and when another figure is set. The
GLFW callbacks build the event and call it, so a windowless host or a test can
drive the routing too.

Two neighbours in the same seam:

* `sync_figure_crosshair()` called the four-argument overload unconditionally,
  so a documented vertical-only crosshair (`horizontal == false`) was promoted
  to a full cross at the stored y == 0, clamped to the axes edge and made
  permanent; the change detection also keyed on x alone, missing a y-only move
  to another row at the same x (two rows showing one horizontal line, spec 17).
  It now consults `crosshair().horizontal` and compares the hovered row's y.
* Only the row under the pointer republished its hot handles, so a row the
  pointer left kept drawing a hovered marker or bound as if it were still
  under the pointer, and answered `hovered_region()`/`grabbed_cursor()` for
  it. The run loop clears every other row's handles, keeping the captured
  row while a drag owns the button, and reads the frame's one layout once
  instead of recomputing every row five times.

Test: a press on row A's region, a motion into row B, a release in row B
asserts no controller keeps a drag mode, nothing reports a grabbed handle, and
a later press-and-motion that re-enters row A does not resume the old edit.
This commit is contained in:
Martino Ferrari committed 2026-09-28 22:36:30 +02:00
1 parent 81792dcd05
commit 9aff06acee
3 files changed
+214 -13

No files matched your search

+88 -9
View File
@@ -210,12 +210,13 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
// that hands each row its rect (and so its own `Plot` its viewport). The single
// controller is untouched -- each row has its own, see controller_at().
figure_->set_viewport(viewport);
figure_->layout();
// Every row's controller gets that row's rect, so a gesture -- routed by
// controller_at() or injected by a scripted run -- is hit-tested against the
// pointer_target() or injected by a scripted run -- is hit-tested against the
// geometry the last frame drew, and the two readers of a row's geometry cannot
// disagree about it.
const std::vector<Rect>& rows = figure_->layout().rows; // the one layout of this frame
// disagree about it. The rows come off the one recomputing layout of this frame --
// calling `layout()` again here, or in `controller_at()`, would lay every row out a
// second and a third time for the same frame.
const std::vector<Rect>& rows = figure_->layout().rows;
for (u32 i = 0; i < row_controllers_.size(); ++i) {
if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]);
}
@@ -225,6 +226,18 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
// signals at the one x the pointer is over. In the run loop rather than in a callback, so a
// scripted run that injects its events straight into a row controller is synced too.
sync_figure_crosshair(rows);
// Only the row the pointer's events go to ever republishes its own hot handles, so a row
// the pointer left keeps the `HotHandles` it last published and goes on drawing a hovered
// marker or region bound as if the pointer were still on it -- and its `hovered_region()`/
// `grabbed_cursor()` keep answering for it. Clear every *other* row here, in the run loop,
// where that row is known (the captured row while a drag owns the button, so an in-flight
// gesture keeps its highlight).
if (have_pointer_ && !rows.empty()) {
const u32 active = pointer_row(rows);
for (u32 i = 0; i < rows.size() && i < row_controllers_.size(); ++i) {
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
}
}
} else {
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
}
@@ -340,7 +353,7 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
have_pointer_ = true;
e.mods = mods_now(window_);
e.down = any_button_down(window_);
controller_at(e.x, e.y).on_pointer(e);
feed_pointer_event(e);
}
void Host::on_mouse_button(int button, int action, int mods) noexcept {
@@ -367,7 +380,7 @@ void Host::on_mouse_button(int button, int action, int mods) noexcept {
e.pressed = action == GLFW_PRESS;
e.released = action == GLFW_RELEASE;
e.down = any_button_down(window_);
controller_at(e.x, e.y).on_pointer(e);
feed_pointer_event(e);
}
void Host::on_scroll(f64 dx, f64 dy) noexcept {
@@ -418,8 +431,13 @@ void Host::set_figure(Figure* figure) noexcept {
last_row_ = 0u;
// A new figure holds no crosshair this host has spread: the comparison in
// `sync_figure_crosshair()` starts from "nothing spread", which is the state its rows are in.
// Nor does it hold a captured drag row: the controllers are rebuilt below, so the old
// capture names a controller that no longer exists.
spread_active_ = false;
spread_x_ = 0.0;
spread_y_ = 0.0;
captured_ = false;
captured_row_ = 0u;
if (figure_ == nullptr) return;
const u32 rows = figure_->row_count();
row_controllers_.reserve(rows);
@@ -449,6 +467,49 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
return controller;
}
void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
if (figure_ == nullptr) {
controller_.on_pointer(e);
return;
}
pointer_target(e.x, e.y, e.pressed, e.released).on_pointer(e);
}
input::Controller& Host::pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept {
(void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in()
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const std::vector<Rect>& rows = figure_->layout().rows;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
u32 row = 0u;
if (captured_) {
// A button-down gesture belongs to the row it began in for its whole life: a drag
// that crosses into a neighbouring row must not hand its motion -- or its release --
// to a controller that never saw the press, which would leave the owner's drag open
// for ever (and let a later gesture resume it from the old anchor).
row = captured_row_;
if (released) captured_ = false;
} else {
row = row_in(rows, y);
if (pressed) {
captured_ = true;
captured_row_ = row;
}
}
// The captured row is re-read against this frame's rects: the figure can be resized
// mid-drag, and the guard keeps a stale index from reaching past the controller list.
if (row >= row_controllers_.size()) row = 0u;
last_row_ = row;
input::Controller& controller = *row_controllers_[row];
controller.set_viewport(rows[row]);
return controller;
}
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
if (captured_ && captured_row_ < rows.size()) return captured_row_;
return row_in(rows, last_pointer_y_);
}
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
if (rows.empty()) return 0u;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
@@ -485,11 +546,18 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
// keeps it.
u32 hovered = row_count;
if (have_pointer_) {
hovered = row_in(rows, last_pointer_y_);
// The row the pointer's events currently go to -- the captured row while a drag owns
// the button, the hovered row otherwise -- so the hover follows the gesture it began.
hovered = pointer_row(rows);
} else {
for (u32 i = 0; i < row_count; ++i) {
const Crosshair& candidate = figure_->plot(i).crosshair();
if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) {
// A row differs when its active state, its x, or the y of *its own* horizontal line
// differs from what was spread: a pointer that moves to another row at the same x is
// a new hover and must move the one horizontal line, not leave the old row's behind.
const bool y_differs = candidate.horizontal && candidate.y != spread_y_;
if (candidate.active != spread_active_ ||
(candidate.active && (candidate.x != spread_x_ || y_differs))) {
hovered = i;
break;
}
@@ -505,8 +573,19 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
// figure's, so the rows cannot be left showing a hover the pointer has abandoned.
const Plot& row = figure_->plot(hovered);
if (row.crosshair().active) {
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
if (row.crosshair().horizontal) {
// The row carries its own y (its controller placed both coordinates), so the figure's
// four-argument overload gives the pointer row the one horizontal line.
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
} else {
// The row's crosshair is vertical-only -- a caller placed it through the
// single-coordinate `Figure::set_crosshair()`, which is documented vertical-only. The
// four-argument overload would turn the horizontal line on at the stored (meaningless)
// y == 0, clamped to the axes edge and made permanent. Promote only the vertical line.
figure_->set_crosshair(row.crosshair().x, true);
}
spread_x_ = row.crosshair().x;
spread_y_ = row.crosshair().y;
spread_active_ = true;
} else {
figure_->clear_crosshair();
+40 -4
View File
@@ -135,11 +135,25 @@ class Host {
/// that leaves the data area still ends where it should.
///
/// It also hands that row its own viewport, which is what keeps the controller's
/// hit-test geometry in step with the layout between two frames. The event callbacks
/// route through it, and so can a host that wants to know where a synthetic gesture
/// would land.
/// hit-test geometry in step with the layout between two frames. It is the pure
/// query -- what a gesture *would* land on -- and it does not move the capture
/// below; the GLFW callbacks and `feed_pointer_event()` are the routing.
input::Controller& controller_at(f32 x, f32 y) noexcept;
/// Feeds one pointer event, stated in framebuffer pixels, through the host's own
/// routing and into the controller it belongs to. The GLFW callbacks fill the
/// position and the button transitions out of GLFW and call exactly this, so a host
/// or a test with no window can drive the routing directly.
///
/// The routing is the row the event lands in, with one exception that is the whole
/// point of it: a press **captures** the row it landed in, and every motion and the
/// release until that button comes up go to the captured row. A drag that crosses
/// into a neighbouring figure row is therefore still the drag it began as -- the
/// owning controller sees the motion and the release that end it -- rather than
/// being handed to a controller whose `mode_ == None`, which would leave the owner's
/// drag open for ever and let a later gesture resume it from the old anchor.
void feed_pointer_event(const input::PointerEvent& e) noexcept;
/// The gesture controller the host feeds. Valid for the host's whole life.
input::Controller& controller() noexcept { return controller_; }
@@ -183,6 +197,19 @@ class Host {
/// that has just laid the figure out does not make it lay out again for this.
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
/// The controller a pointer event at (x, y) belongs to, applying the button capture:
/// a press claims the row it landed in (`captured_row_`) and every motion and the
/// release until the button comes up are routed to it; `controller_at()` is the pure
/// query that never captures. Also hands the target its row's viewport, so the
/// hit-test geometry is the last frame's.
input::Controller& pointer_target(f32 x, f32 y, bool pressed, bool released) noexcept;
/// The row the pointer's events currently go to: the captured row while a drag owns
/// the button, the row under the pointer otherwise. The run loop uses it to clear
/// every *other* row's hot handles, so a row the pointer left cannot keep drawing a
/// hover or a grab it last published.
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
/// Spreads the hover over a whole `Figure` (spec §15).
///
/// A figure's rows share one x, so the crosshair one row's controller placed is read back here and
@@ -220,10 +247,19 @@ class Host {
/// `sync_figure_crosshair()`): what a row is compared against to find the one a
/// scripted run touched, since the figure writes one value to every row.
/// `spread_active_` false means "spread nothing" -- the state before the first
/// sync, and the one a clear leaves.
/// sync, and the one a clear leaves. `spread_y_` is the horizontal line's own
/// value, compared as well so a y-only change -- the same x in another row --
/// is not mistaken for "nothing changed".
f64 spread_x_ = 0.0;
f64 spread_y_ = 0.0;
bool spread_active_ = false;
/// The row a button-down drag has captured: the row its press landed in, which
/// owns its motions and its release until the button comes up. `captured_` is
/// false between gestures, and cleared when another figure is set.
bool captured_ = false;
u32 captured_row_ = 0u;
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
u32 fb_height_ = 0;
/// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports