wip(plot): region layout, hot handles and draggable handles (build restored)

This commit is contained in:
Martino Ferrari committed 2026-09-28 13:28:51 +02:00
1 parent 7d2349555d
commit e4d48cc6c3
9 files changed
+915 -37

No files matched your search

+199 -14
View File
@@ -33,6 +33,14 @@ constexpr f32 kCursorHitRadiusLogical = 5.0f;
/// A box-zoom selection narrower than this on either side is a click. Logical for
/// the same reason: the gesture is the same hand movement on any display.
constexpr f32 kMinBoxLogical = 4.0f;
/// The narrowest region whose bounds are grabbable, in **logical** pixels. Closer
/// together than this, the two bounds are one target the pointer cannot separate:
/// an edge would win the hit test every time and the region could never be moved
/// again, so a *narrower* region is grabbed by its body -- widened to this same
/// minimum around its bounds, which keeps a collapsed region something a pointer can
/// still take hold of rather than a line. Logical like the two tolerances above, and
/// converted through the same one call.
constexpr f32 kMinRegionEdgeGrabLogical = 3.0f;
/// An axis endpoint in transform space: log10 when the axis is logarithmic, the
/// value itself otherwise. Pan and zoom arithmetic runs in this space so a log
@@ -96,6 +104,9 @@ void Controller::set_viewport(Rect px) {
// transforms read.
plot_->set_viewport(px);
plot_->layout();
// The hover the layout is part of: a handle under the pointer may be somewhere
// else now, and the highlight the renderer draws reads what this publishes.
publish_hot();
}
void Controller::on_pointer(const PointerEvent& raw) {
@@ -107,13 +118,15 @@ void Controller::on_pointer(const PointerEvent& raw) {
if (e.pressed && e.down) {
begin_drag(e);
return;
}
if (e.released) {
} else if (e.released) {
end_drag(e);
return;
} else if (e.down && mode_ != DragMode::None) {
update_drag(e);
}
if (e.down && mode_ != DragMode::None) update_drag(e);
// Every exit above ends here: a hover that moved, a gesture that opened, and a
// gesture that closed all change what is under the pointer, and the publish is
// what makes the renderer's highlight (and a host's resize cursor) follow.
publish_hot();
}
void Controller::on_wheel(const WheelEvent& raw) {
@@ -180,27 +193,112 @@ u64 Controller::hovered_cursor() const {
return cursor_at(pointer_x_, pointer_y_);
}
u64 Controller::hovered_region() const {
if (!has_pointer_) return kInvalid;
return region_hot(pointer_x_, pointer_y_).region;
}
RegionGrab Controller::region_grab(u32 region) const {
if (!has_pointer_) return RegionGrab::None;
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
if (candidate.region == region) return zone_hit(candidate, pointer_x_, pointer_y_).zone;
}
return RegionGrab::None; // an id that names no visible region
}
Controller::ZoneHit Controller::zone_hit(const RegionLayout& region, f32 px, f32 py) const {
ZoneHit hit;
// Off the data area nothing is hot, whether the pointer is over a label strip or
// off the plot entirely. A bound that has scrolled just outside `axes` is still
// within reach of a pointer inside it -- the slack the cursor hit test has too --
// but the strips are not a place a region can be grabbed from.
if (!std::as_const(*plot_).layout().axes.contains(px, py)) return hit;
const f32 left_x = handle_column(region.left_handle);
const f32 right_x = handle_column(region.right_handle);
const f32 min_w = plot_->style().device(kMinRegionEdgeGrabLogical);
// Grabbable bounds: the nearer of the two, when it is within the radius every
// other handle in this controller is hit-tested with -- one tolerance, so what
// `hovered_cursor()` calls a hit and what a region bound calls a hit agree.
if (right_x - left_x >= min_w) {
const f32 dl = std::fabs(px - left_x);
const f32 dr = std::fabs(px - right_x);
const f32 d = std::min(dl, dr);
if (d <= plot_->style().device(kCursorHitRadiusLogical)) {
hit.zone = dl <= dr ? RegionGrab::Left : RegionGrab::Right;
hit.distance = d;
return hit;
}
}
// No bound in range: the body, widened by half the minimum on each side so a
// region too narrow to have grabbable bounds is still something a pointer can take
// hold of rather than a line it cannot hit.
const f32 grow = 0.5f * min_w;
if (px >= left_x - grow && px <= right_x + grow) hit.zone = RegionGrab::Body;
return hit;
}
Controller::RegionHot Controller::region_hot(f32 px, f32 py) const {
RegionHot hot;
u64 body = kInvalid;
f32 nearest = 0.0f;
for (const RegionLayout& candidate : std::as_const(*plot_).layout().regions) {
const ZoneHit hit = zone_hit(candidate, px, py);
if (hit.zone == RegionGrab::None) continue;
if (hit.zone == RegionGrab::Body) {
// Remembered rather than chosen: a body only wins when no bound *anywhere* is
// in range, and the last region holding the pointer is the one on top --
// regions are drawn in order, so a later one covers an earlier one's interior.
body = candidate.region;
continue;
}
if (hot.region == kInvalid || hit.distance < nearest) {
hot.region = candidate.region;
hot.zone = hit.zone;
nearest = hit.distance;
}
}
if (hot.region != kInvalid) return hot;
return body == kInvalid ? RegionHot{} : RegionHot{body, RegionGrab::Body};
}
void Controller::publish_hot() {
HotHandles hot;
if (has_pointer_) {
hot.cursor = cursor_at(pointer_x_, pointer_y_);
if (drag_region_ != kInvalid) {
// An open region drag keeps its own zone hot for as long as it runs, so the
// highlight follows the grab rather than the pointer: a body dragged out from
// under the pointer still reads as the region being moved, and a bound keeps
// its emphasis after the pointer has left it.
hot.region = drag_region_;
hot.zone = drag_zone_;
} else {
const RegionHot region = region_hot(pointer_x_, pointer_y_);
hot.region = region.region;
hot.zone = region.zone;
}
}
hot.grabbed_cursor = drag_cursor_;
plot_->set_hot_handles(hot);
}
// --- drag dispatch --------------------------------------------------------
void Controller::begin_drag(const PointerEvent& e) {
origin_x_ = e.x;
origin_y_ = e.y;
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
// Shift first, and only on the left: it is a modifier gesture of its own, and a
// selection box is what it means even when it starts over a handle.
if (e.button == Button::Left && has_mod(e.mods, Mod::Shift)) {
mode_ = DragMode::BoxZoom;
selection_ = Rect{e.x, e.y, 0.0f, 0.0f};
return;
}
if (e.button == Button::Left && has_mod(e.mods, Mod::Ctrl)) {
const u64 hit = cursor_at(e.x, e.y);
if (hit != kInvalid) {
mode_ = DragMode::MoveCursor;
drag_cursor_ = hit;
return;
}
// Ctrl far from any cursor falls through to pan, like an unmodified drag.
}
if (e.button == Button::Middle) {
const PlotLayout& lay = std::as_const(*plot_).layout();
if (lay.x_label_area.contains(e.x, e.y)) {
@@ -212,14 +310,68 @@ void Controller::begin_drag(const PointerEvent& e) {
}
return;
}
// Everything else -- the left and the right button both, since a host that
// reserves the left one still drives its drags with the right -- grabs the handle
// under the press before it pans. Ctrl needs no special case: a press on a cursor
// does exactly this with or without it, and a ctrl press on nothing still pans.
if (begin_handle_drag(e)) return;
mode_ = DragMode::Pan; // left and right drag both pan
}
bool Controller::begin_handle_drag(const PointerEvent& e) {
// Cursor markers first: they are what a measure's band edges *are* (a band
// between two cursors is resized by dragging those cursors), and a marker is drawn
// over everything else, so it is the thing a press on it means.
const u64 cursor = cursor_at(e.x, e.y);
if (cursor != kInvalid) {
mode_ = DragMode::MoveCursor;
drag_cursor_ = cursor;
return true;
}
const RegionHot hot = region_hot(e.x, e.y);
if (hot.region == kInvalid) return false;
drag_region_ = hot.region;
drag_zone_ = hot.zone;
const Region& region = plot_->region(static_cast<u32>(hot.region));
switch (hot.zone) {
case RegionGrab::Left:
// The other bound is held for the whole gesture: each motion is then the two
// numbers this drag is made of, not a step from the previous one, so a pointer
// that moves back and forth cannot accumulate a drift -- and when it crosses
// the held bound the store orders them, which swaps the interval instead of
// inverting it (see Plot::set_region).
mode_ = DragMode::ResizeRegionLeft;
drag_fixed_x_ = region.x1;
return true;
case RegionGrab::Right:
mode_ = DragMode::ResizeRegionRight;
drag_fixed_x_ = region.x0;
return true;
case RegionGrab::Body:
// A body drag preserves the width exactly by construction: both bounds move by
// the same delta from where they were when the gesture opened.
mode_ = DragMode::MoveRegion;
drag_x0_ = region.x0;
drag_x1_ = region.x1;
drag_grab_x_ = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
return true;
case RegionGrab::None:
break;
}
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
return false;
}
void Controller::update_drag(const PointerEvent& e) {
switch (mode_) {
case DragMode::Pan: pan(e); break;
case DragMode::BoxZoom: box_zoom_update(e); break;
case DragMode::MoveCursor: move_cursor(e); break;
case DragMode::MoveRegion: move_region(e); break;
case DragMode::ResizeRegionLeft:
case DragMode::ResizeRegionRight: resize_region(e); break;
case DragMode::AxisZoomX:
case DragMode::AxisZoomY: axis_zoom(e); break;
case DragMode::None: break;
@@ -235,9 +387,15 @@ void Controller::end_drag(const PointerEvent& e) {
box_zoom_update(e);
apply_box_zoom();
}
// A region's snap is applied exactly once, here, on the gesture's final bounds:
// the drag itself stores them verbatim, so they follow the pointer instead of
// sticking to the sample grid as it moves (see Plot::snap_region()).
if (drag_region_ != kInvalid) plot_->snap_region(static_cast<u32>(drag_region_));
mode_ = DragMode::None;
selection_ = Rect{};
drag_cursor_ = kInvalid;
drag_region_ = kInvalid;
drag_zone_ = RegionGrab::None;
}
// --- gestures -------------------------------------------------------------
@@ -315,6 +473,33 @@ void Controller::move_cursor(const PointerEvent& e) {
plot_->set_cursor_x(id, plot_->x_transform(cursor.axis_x).to_data_x(static_cast<f64>(e.x)));
}
void Controller::move_region(const PointerEvent& e) {
if (drag_region_ == kInvalid) return;
// The pointer's own data coordinate, not the event's delta: the delta a host
// reports for one motion is not always the difference between the positions it
// reported, and a region's two bounds must move by exactly the same amount or the
// width changes. Both come from the bounds the gesture opened with plus this one
// offset, so nothing accumulates over a long drag.
const f64 grabbed = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
const f64 delta = grabbed - drag_grab_x_;
plot_->set_region(static_cast<u32>(drag_region_), drag_x0_ + delta, drag_x1_ + delta);
}
void Controller::resize_region(const PointerEvent& e) {
if (drag_region_ == kInvalid) return;
const f64 x = plot_->x_transform(0u).to_data_x(static_cast<f64>(e.x));
// The bound the gesture did not grab stays where it was for the whole drag, and
// the grabbed one is wherever the pointer says. When the two cross, the *store*
// orders them (`set_region` keeps min and max), so the interval swaps rather than
// inverting -- and the pointer keeps hold of its bound: the next motion is still
// "the other end plus this pointer", with no state to get stuck in.
if (mode_ == DragMode::ResizeRegionLeft) {
plot_->set_region(static_cast<u32>(drag_region_), x, drag_fixed_x_);
} else {
plot_->set_region(static_cast<u32>(drag_region_), drag_fixed_x_, x);
}
}
void Controller::axis_zoom(const PointerEvent& e) {
// A pointer drag reports pixels, not wheel notches, so it uses its own
// sensitivity: 200 px is one decade. The gain is a physical distance the hand